1#[cfg(feature = "python")]
2use crate::utilities::kernel_validation::validate_kernel;
3#[cfg(feature = "python")]
4use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
5#[cfg(feature = "python")]
6use pyo3::exceptions::PyValueError;
7#[cfg(feature = "python")]
8use pyo3::prelude::*;
9#[cfg(feature = "python")]
10use pyo3::types::PyDict;
11#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
12use serde::{Deserialize, Serialize};
13
14use crate::utilities::data_loader::{source_type, Candles};
15use crate::utilities::enums::Kernel;
16use crate::utilities::helpers::{
17 alloc_with_nan_prefix, detect_best_batch_kernel, detect_best_kernel, init_matrix_prefixes,
18 make_uninit_matrix,
19};
20use aligned_vec::{AVec, CACHELINE_ALIGN};
21#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
22use core::arch::x86_64::*;
23#[cfg(not(target_arch = "wasm32"))]
24use rayon::prelude::*;
25use std::mem::MaybeUninit;
26use thiserror::Error;
27
28#[cfg(all(feature = "python", feature = "cuda"))]
29use crate::cuda::CudaUi;
30#[cfg(all(feature = "python", feature = "cuda"))]
31use crate::utilities::dlpack_cuda::{make_device_array_py, DeviceArrayF32Py};
32
33impl<'a> AsRef<[f64]> for UiInput<'a> {
34 #[inline(always)]
35 fn as_ref(&self) -> &[f64] {
36 match &self.data {
37 UiData::Slice(slice) => slice,
38 UiData::Candles { candles, source } => source_type(candles, source),
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
44pub enum UiData<'a> {
45 Candles {
46 candles: &'a Candles,
47 source: &'a str,
48 },
49 Slice(&'a [f64]),
50}
51
52#[derive(Debug, Clone)]
53pub struct UiOutput {
54 pub values: Vec<f64>,
55}
56
57#[derive(Debug, Clone)]
58#[cfg_attr(
59 all(target_arch = "wasm32", feature = "wasm"),
60 derive(serde::Serialize, serde::Deserialize)
61)]
62pub struct UiParams {
63 pub period: Option<usize>,
64 pub scalar: Option<f64>,
65}
66
67impl Default for UiParams {
68 fn default() -> Self {
69 Self {
70 period: Some(14),
71 scalar: Some(100.0),
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
77pub struct UiInput<'a> {
78 pub data: UiData<'a>,
79 pub params: UiParams,
80}
81
82impl<'a> UiInput<'a> {
83 #[inline]
84 pub fn from_candles(c: &'a Candles, s: &'a str, p: UiParams) -> Self {
85 Self {
86 data: UiData::Candles {
87 candles: c,
88 source: s,
89 },
90 params: p,
91 }
92 }
93 #[inline]
94 pub fn from_slice(sl: &'a [f64], p: UiParams) -> Self {
95 Self {
96 data: UiData::Slice(sl),
97 params: p,
98 }
99 }
100 #[inline]
101 pub fn with_default_candles(c: &'a Candles) -> Self {
102 Self::from_candles(c, "close", UiParams::default())
103 }
104 #[inline]
105 pub fn get_period(&self) -> usize {
106 self.params.period.unwrap_or(14)
107 }
108 #[inline]
109 pub fn get_scalar(&self) -> f64 {
110 self.params.scalar.unwrap_or(100.0)
111 }
112}
113
114#[derive(Copy, Clone, Debug)]
115pub struct UiBuilder {
116 period: Option<usize>,
117 scalar: Option<f64>,
118 kernel: Kernel,
119}
120
121impl Default for UiBuilder {
122 fn default() -> Self {
123 Self {
124 period: None,
125 scalar: None,
126 kernel: Kernel::Auto,
127 }
128 }
129}
130
131impl UiBuilder {
132 #[inline(always)]
133 pub fn new() -> Self {
134 Self::default()
135 }
136 #[inline(always)]
137 pub fn period(mut self, n: usize) -> Self {
138 self.period = Some(n);
139 self
140 }
141 #[inline(always)]
142 pub fn scalar(mut self, s: f64) -> Self {
143 self.scalar = Some(s);
144 self
145 }
146 #[inline(always)]
147 pub fn kernel(mut self, k: Kernel) -> Self {
148 self.kernel = k;
149 self
150 }
151 #[inline(always)]
152 pub fn apply(self, c: &Candles) -> Result<UiOutput, UiError> {
153 let p = UiParams {
154 period: self.period,
155 scalar: self.scalar,
156 };
157 let i = UiInput::from_candles(c, "close", p);
158 ui_with_kernel(&i, self.kernel)
159 }
160 #[inline(always)]
161 pub fn apply_slice(self, d: &[f64]) -> Result<UiOutput, UiError> {
162 let p = UiParams {
163 period: self.period,
164 scalar: self.scalar,
165 };
166 let i = UiInput::from_slice(d, p);
167 ui_with_kernel(&i, self.kernel)
168 }
169 #[inline(always)]
170 pub fn into_stream(self) -> Result<UiStream, UiError> {
171 let p = UiParams {
172 period: self.period,
173 scalar: self.scalar,
174 };
175 UiStream::try_new(p)
176 }
177}
178
179#[derive(Debug, Error)]
180pub enum UiError {
181 #[error("ui: Empty input data")]
182 EmptyInputData,
183 #[error("ui: All values are NaN.")]
184 AllValuesNaN,
185 #[error("ui: Invalid period: period = {period}, data length = {data_len}")]
186 InvalidPeriod { period: usize, data_len: usize },
187 #[error("ui: Not enough valid data: needed = {needed}, valid = {valid}")]
188 NotEnoughValidData { needed: usize, valid: usize },
189 #[error("ui: Output length mismatch: expected = {expected}, got = {got}")]
190 OutputLengthMismatch { expected: usize, got: usize },
191 #[error("ui: Invalid scalar: {scalar}")]
192 InvalidScalar { scalar: f64 },
193 #[error("ui: Invalid range: start = {start}, end = {end}, step = {step}")]
194 InvalidRange { start: f64, end: f64, step: f64 },
195 #[error("ui: Invalid kernel for batch operation. Expected batch kernel, got: {0:?}")]
196 InvalidKernelForBatch(Kernel),
197
198 #[error("ui: Empty input")]
199 EmptyInput,
200 #[error("ui: Invalid length: expected = {expected}, actual = {actual}")]
201 InvalidLength { expected: usize, actual: usize },
202}
203
204#[inline]
205pub fn ui(input: &UiInput) -> Result<UiOutput, UiError> {
206 ui_with_kernel(input, Kernel::Auto)
207}
208
209pub fn ui_with_kernel(input: &UiInput, kernel: Kernel) -> Result<UiOutput, UiError> {
210 let data: &[f64] = input.as_ref();
211 let len = data.len();
212 if len == 0 {
213 return Err(UiError::EmptyInputData);
214 }
215
216 let first = data
217 .iter()
218 .position(|x| x.is_finite())
219 .ok_or(UiError::AllValuesNaN)?;
220 let period = input.get_period();
221 let scalar = input.get_scalar();
222
223 if period == 0 || period > len {
224 return Err(UiError::InvalidPeriod {
225 period,
226 data_len: len,
227 });
228 }
229 if (len - first) < period {
230 return Err(UiError::NotEnoughValidData {
231 needed: period,
232 valid: len - first,
233 });
234 }
235 if !scalar.is_finite() {
236 return Err(UiError::InvalidScalar { scalar });
237 }
238
239 let chosen = match kernel {
240 Kernel::Auto => detect_best_kernel(),
241 other => other,
242 };
243
244 let span =
245 period
246 .checked_mul(2)
247 .and_then(|v| v.checked_sub(2))
248 .ok_or(UiError::InvalidRange {
249 start: period as f64,
250 end: len as f64,
251 step: 2.0,
252 })?;
253 let warmup = first.checked_add(span).ok_or(UiError::InvalidRange {
254 start: period as f64,
255 end: len as f64,
256 step: 2.0,
257 })?;
258 let mut out = alloc_with_nan_prefix(len, warmup.min(len));
259
260 match chosen {
261 Kernel::Scalar | Kernel::ScalarBatch => ui_scalar(data, period, scalar, first, &mut out),
262 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
263 Kernel::Avx2 | Kernel::Avx2Batch => ui_avx2(data, period, scalar, first, &mut out),
264 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
265 Kernel::Avx512 | Kernel::Avx512Batch => ui_avx512(data, period, scalar, first, &mut out),
266 _ => ui_scalar(data, period, scalar, first, &mut out),
267 }
268
269 Ok(UiOutput { values: out })
270}
271
272#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
273pub fn ui_into(input: &UiInput, out: &mut [f64]) -> Result<(), UiError> {
274 let data: &[f64] = input.as_ref();
275 let len = data.len();
276 if len == 0 {
277 return Err(UiError::EmptyInputData);
278 }
279
280 if out.len() != len {
281 return Err(UiError::OutputLengthMismatch {
282 expected: len,
283 got: out.len(),
284 });
285 }
286
287 let first = data
288 .iter()
289 .position(|x| x.is_finite())
290 .ok_or(UiError::AllValuesNaN)?;
291 let period = input.get_period();
292 let scalar = input.get_scalar();
293
294 if period == 0 || period > len {
295 return Err(UiError::InvalidPeriod {
296 period,
297 data_len: len,
298 });
299 }
300 if (len - first) < period {
301 return Err(UiError::NotEnoughValidData {
302 needed: period,
303 valid: len - first,
304 });
305 }
306 if !scalar.is_finite() {
307 return Err(UiError::InvalidScalar { scalar });
308 }
309
310 let chosen = detect_best_kernel();
311
312 let span =
313 period
314 .checked_mul(2)
315 .and_then(|v| v.checked_sub(2))
316 .ok_or(UiError::InvalidRange {
317 start: period as f64,
318 end: len as f64,
319 step: 2.0,
320 })?;
321 let warmup = first.checked_add(span).ok_or(UiError::InvalidRange {
322 start: period as f64,
323 end: len as f64,
324 step: 2.0,
325 })?;
326 let nan_q = f64::from_bits(0x7ff8_0000_0000_0000);
327 for v in &mut out[..warmup.min(len)] {
328 *v = nan_q;
329 }
330
331 match chosen {
332 Kernel::Scalar | Kernel::ScalarBatch => ui_scalar(data, period, scalar, first, out),
333 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
334 Kernel::Avx2 | Kernel::Avx2Batch => ui_avx2(data, period, scalar, first, out),
335 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
336 Kernel::Avx512 | Kernel::Avx512Batch => ui_avx512(data, period, scalar, first, out),
337 _ => ui_scalar(data, period, scalar, first, out),
338 }
339
340 Ok(())
341}
342
343pub fn ui_scalar(data: &[f64], period: usize, scalar: f64, first: usize, out: &mut [f64]) {
344 debug_assert_eq!(out.len(), data.len());
345 let len = data.len();
346 if len == 0 {
347 return;
348 }
349
350 let inv_period = 1.0 / (period as f64);
351 let warmup_end = first + (period * 2 - 2);
352
353 let cap = period;
354 let mut deq: Vec<usize> = vec![0usize; cap];
355 let mut head = 0usize;
356 let mut tail = 0usize;
357 let mut dsize = 0usize;
358
359 #[inline(always)]
360 fn inc_wrap(x: &mut usize, cap: usize) {
361 *x += 1;
362 if *x == cap {
363 *x = 0;
364 }
365 }
366 #[inline(always)]
367 fn dec_wrap(x: &mut usize, cap: usize) {
368 if *x == 0 {
369 *x = cap - 1;
370 } else {
371 *x -= 1;
372 }
373 }
374
375 let mut sq_ring: Vec<f64> = vec![0.0f64; period];
376 let mut ring_idx = 0usize;
377 let mut sum = 0.0f64;
378 let mut count = 0usize;
379
380 if false && period <= 64 {
381 let mut valid_mask: u64 = 0;
382
383 for i in first..len {
384 let start = if i + 1 >= period { i + 1 - period } else { 0 };
385
386 while dsize != 0 {
387 let j = unsafe { *deq.get_unchecked(head) };
388 if j < start {
389 inc_wrap(&mut head, cap);
390 dsize -= 1;
391 } else {
392 break;
393 }
394 }
395
396 let xi = unsafe { *data.get_unchecked(i) };
397 let xi_finite = xi.is_finite();
398 if xi_finite {
399 while dsize != 0 {
400 let mut back = tail;
401 dec_wrap(&mut back, cap);
402 let j = unsafe { *deq.get_unchecked(back) };
403 let xj = unsafe { *data.get_unchecked(j) };
404 if xj <= xi {
405 tail = back;
406 dsize -= 1;
407 } else {
408 break;
409 }
410 }
411
412 unsafe { *deq.get_unchecked_mut(tail) = i };
413 inc_wrap(&mut tail, cap);
414 dsize += 1;
415 }
416
417 let mut new_valid = false;
418 let mut new_sq: f64 = 0.0;
419 if i + 1 >= first + period && dsize != 0 {
420 let jmax = unsafe { *deq.get_unchecked(head) };
421 let m = unsafe { *data.get_unchecked(jmax) };
422 if xi_finite && m.is_finite() && m.abs() > f64::EPSILON {
423 let dd = (xi - m) * (scalar / m);
424 new_sq = dd.mul_add(dd, 0.0);
425 new_valid = true;
426 }
427 }
428
429 let bit = 1u64 << ring_idx;
430 if (valid_mask & bit) != 0 {
431 sum -= unsafe { *sq_ring.get_unchecked(ring_idx) };
432 count -= 1;
433 valid_mask &= !bit;
434 }
435 if new_valid {
436 sum += new_sq;
437 count += 1;
438 valid_mask |= bit;
439 }
440 unsafe { *sq_ring.get_unchecked_mut(ring_idx) = new_sq };
441
442 ring_idx += 1;
443 if ring_idx == period {
444 ring_idx = 0;
445 }
446
447 if i >= warmup_end {
448 let dst = unsafe { out.get_unchecked_mut(i) };
449 if count == period {
450 let mut avg = sum * inv_period;
451 if avg < 0.0 {
452 avg = 0.0;
453 }
454 *dst = avg.sqrt();
455 } else {
456 *dst = f64::NAN;
457 }
458 }
459 }
460 return;
461 }
462
463 let mut valid_ring: Vec<u8> = vec![0u8; period];
464
465 for i in first..len {
466 let start = if i + 1 >= period { i + 1 - period } else { 0 };
467
468 while dsize != 0 {
469 let j = unsafe { *deq.get_unchecked(head) };
470 if j < start {
471 inc_wrap(&mut head, cap);
472 dsize -= 1;
473 } else {
474 break;
475 }
476 }
477
478 let xi = unsafe { *data.get_unchecked(i) };
479 let xi_finite = xi.is_finite();
480 if xi_finite {
481 while dsize != 0 {
482 let mut back = tail;
483 dec_wrap(&mut back, cap);
484 let j = unsafe { *deq.get_unchecked(back) };
485 let xj = unsafe { *data.get_unchecked(j) };
486 if xj <= xi {
487 tail = back;
488 dsize -= 1;
489 } else {
490 break;
491 }
492 }
493
494 unsafe { *deq.get_unchecked_mut(tail) = i };
495 inc_wrap(&mut tail, cap);
496 dsize += 1;
497 }
498
499 let mut new_valid: u8 = 0;
500 let mut new_sq: f64 = 0.0;
501
502 if i + 1 >= first + period && dsize != 0 {
503 let jmax = unsafe { *deq.get_unchecked(head) };
504 let m = unsafe { *data.get_unchecked(jmax) };
505
506 if xi_finite && m.is_finite() && m.abs() > f64::EPSILON {
507 let scaled = scalar / m;
508 let diff = xi - m;
509 let dd = diff * scaled;
510 new_sq = dd.mul_add(dd, 0.0);
511 new_valid = 1;
512 }
513 }
514
515 let old_valid = unsafe { *valid_ring.get_unchecked(ring_idx) };
516 if old_valid != 0 {
517 sum -= unsafe { *sq_ring.get_unchecked(ring_idx) };
518 count -= 1;
519 }
520 if new_valid != 0 {
521 sum += new_sq;
522 count += 1;
523 }
524 unsafe {
525 *sq_ring.get_unchecked_mut(ring_idx) = new_sq;
526 *valid_ring.get_unchecked_mut(ring_idx) = new_valid;
527 }
528 ring_idx += 1;
529 if ring_idx == period {
530 ring_idx = 0;
531 }
532
533 if i >= warmup_end {
534 let dst = unsafe { out.get_unchecked_mut(i) };
535 if count == period {
536 let mut avg = sum * inv_period;
537 if avg < 0.0 {
538 avg = 0.0;
539 }
540 *dst = avg.sqrt();
541 } else {
542 *dst = f64::NAN;
543 }
544 }
545 }
546}
547
548#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
549#[inline]
550pub fn ui_avx512(data: &[f64], period: usize, scalar: f64, first: usize, out: &mut [f64]) {
551 unsafe { ui_avx512_short(data, period, scalar, first, out) }
552}
553
554#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
555#[inline]
556pub fn ui_avx2(data: &[f64], period: usize, scalar: f64, first: usize, out: &mut [f64]) {
557 ui_scalar(data, period, scalar, first, out)
558}
559
560#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
561#[inline]
562pub unsafe fn ui_avx512_short(
563 data: &[f64],
564 period: usize,
565 scalar: f64,
566 first: usize,
567 out: &mut [f64],
568) {
569 ui_scalar(data, period, scalar, first, out)
570}
571
572#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
573#[inline]
574pub unsafe fn ui_avx512_long(
575 data: &[f64],
576 period: usize,
577 scalar: f64,
578 first: usize,
579 out: &mut [f64],
580) {
581 ui_scalar(data, period, scalar, first, out)
582}
583
584#[derive(Debug, Clone)]
585pub struct UiStream {
586 period: usize,
587 scalar: f64,
588
589 i: usize,
590
591 first_finite: Option<usize>,
592
593 warmup_end: Option<usize>,
594
595 buffer: Vec<f64>,
596 deq: Vec<usize>,
597 dq_head: usize,
598 dq_tail: usize,
599 dq_size: usize,
600
601 sq_ring: Vec<f64>,
602 ring_idx: usize,
603
604 valid_mask: u64,
605 valid_ring: Option<Vec<u8>>,
606
607 sum_sq: f64,
608 count_valid: usize,
609}
610
611impl UiStream {
612 pub fn try_new(params: UiParams) -> Result<Self, UiError> {
613 let period = params.period.unwrap_or(14);
614 let scalar = params.scalar.unwrap_or(100.0);
615 if period == 0 {
616 return Err(UiError::InvalidPeriod {
617 period,
618 data_len: 0,
619 });
620 }
621 if !scalar.is_finite() {
622 return Err(UiError::InvalidScalar { scalar });
623 }
624
625 let use_mask = period <= 64;
626 Ok(Self {
627 period,
628 scalar,
629
630 i: 0,
631 first_finite: None,
632 warmup_end: None,
633
634 buffer: vec![f64::NAN; period],
635 deq: vec![0usize; period],
636 dq_head: 0,
637 dq_tail: 0,
638 dq_size: 0,
639
640 sq_ring: vec![0.0; period],
641 ring_idx: 0,
642
643 valid_mask: 0,
644 valid_ring: (!use_mask).then(|| vec![0u8; period]),
645
646 sum_sq: 0.0,
647 count_valid: 0,
648 })
649 }
650
651 #[inline(always)]
652 fn dq_inc(x: &mut usize, cap: usize) {
653 *x += 1;
654 if *x == cap {
655 *x = 0;
656 }
657 }
658 #[inline(always)]
659 fn dq_dec(x: &mut usize, cap: usize) {
660 if *x == 0 {
661 *x = cap - 1;
662 } else {
663 *x -= 1;
664 }
665 }
666
667 #[inline(always)]
668 pub fn update(&mut self, value: f64) -> Option<f64> {
669 let p = self.period;
670 let cap = p;
671
672 let pos = self.i % p;
673 self.buffer[pos] = value;
674
675 if self.first_finite.is_none() && value.is_finite() {
676 let f = self.i;
677 self.first_finite = Some(f);
678 self.warmup_end = Some(f + (p * 2 - 2));
679 }
680
681 let start = if self.i + 1 >= p { self.i + 1 - p } else { 0 };
682 while self.dq_size != 0 {
683 let j = unsafe { *self.deq.get_unchecked(self.dq_head) };
684 if j < start {
685 Self::dq_inc(&mut self.dq_head, cap);
686 self.dq_size -= 1;
687 } else {
688 break;
689 }
690 }
691
692 let xi = value;
693 let xi_finite = xi.is_finite();
694 if xi_finite {
695 while self.dq_size != 0 {
696 let mut back = self.dq_tail;
697 Self::dq_dec(&mut back, cap);
698 let j = unsafe { *self.deq.get_unchecked(back) };
699 let xj = unsafe { *self.buffer.get_unchecked(j % p) };
700 if xj <= xi {
701 self.dq_tail = back;
702 self.dq_size -= 1;
703 } else {
704 break;
705 }
706 }
707 unsafe {
708 *self.deq.get_unchecked_mut(self.dq_tail) = self.i;
709 }
710 Self::dq_inc(&mut self.dq_tail, cap);
711 self.dq_size += 1;
712 }
713
714 let mut new_valid = false;
715 let mut new_sq = 0.0f64;
716
717 if let Some(first) = self.first_finite {
718 if self.i + 1 >= first + p && self.dq_size != 0 {
719 let jmax = unsafe { *self.deq.get_unchecked(self.dq_head) };
720 let m = unsafe { *self.buffer.get_unchecked(jmax % p) };
721 if xi_finite && m.is_finite() && m.abs() > f64::EPSILON {
722 let dd = (xi - m) * (self.scalar / m);
723
724 new_sq = dd.mul_add(dd, 0.0);
725 new_valid = true;
726 }
727 }
728 }
729
730 if self.period <= 64 {
731 let bit = 1u64 << self.ring_idx;
732 if (self.valid_mask & bit) != 0 {
733 self.sum_sq -= unsafe { *self.sq_ring.get_unchecked(self.ring_idx) };
734 self.count_valid -= 1;
735 self.valid_mask &= !bit;
736 }
737 if new_valid {
738 self.sum_sq += new_sq;
739 self.count_valid += 1;
740 self.valid_mask |= bit;
741 }
742 } else {
743 let vr = self.valid_ring.as_mut().unwrap();
744 let was = unsafe { *vr.get_unchecked(self.ring_idx) };
745 if was != 0 {
746 self.sum_sq -= unsafe { *self.sq_ring.get_unchecked(self.ring_idx) };
747 self.count_valid -= 1;
748 }
749 if new_valid {
750 self.sum_sq += new_sq;
751 self.count_valid += 1;
752 }
753 unsafe {
754 *vr.get_unchecked_mut(self.ring_idx) = if new_valid { 1 } else { 0 };
755 }
756 }
757 unsafe {
758 *self.sq_ring.get_unchecked_mut(self.ring_idx) = new_sq;
759 }
760 self.ring_idx += 1;
761 if self.ring_idx == p {
762 self.ring_idx = 0;
763 }
764
765 let i_now = self.i;
766 self.i = self.i.wrapping_add(1);
767
768 if let Some(we) = self.warmup_end {
769 if i_now >= we && self.count_valid == p {
770 let mut avg = self.sum_sq / (p as f64);
771 if avg < 0.0 {
772 avg = 0.0;
773 }
774
775 return Some(avg.sqrt());
776 }
777 }
778 None
779 }
780}
781
782#[derive(Clone, Debug)]
783pub struct UiBatchRange {
784 pub period: (usize, usize, usize),
785 pub scalar: (f64, f64, f64),
786}
787
788impl Default for UiBatchRange {
789 fn default() -> Self {
790 Self {
791 period: (14, 263, 1),
792 scalar: (100.0, 100.0, 0.0),
793 }
794 }
795}
796
797#[derive(Clone, Debug, Default)]
798pub struct UiBatchBuilder {
799 range: UiBatchRange,
800 kernel: Kernel,
801}
802
803impl UiBatchBuilder {
804 pub fn new() -> Self {
805 Self::default()
806 }
807 pub fn kernel(mut self, k: Kernel) -> Self {
808 self.kernel = k;
809 self
810 }
811 #[inline]
812 pub fn period_range(mut self, start: usize, end: usize, step: usize) -> Self {
813 self.range.period = (start, end, step);
814 self
815 }
816 #[inline]
817 pub fn period_static(mut self, p: usize) -> Self {
818 self.range.period = (p, p, 0);
819 self
820 }
821 #[inline]
822 pub fn scalar_range(mut self, start: f64, end: f64, step: f64) -> Self {
823 self.range.scalar = (start, end, step);
824 self
825 }
826 #[inline]
827 pub fn scalar_static(mut self, s: f64) -> Self {
828 self.range.scalar = (s, s, 0.0);
829 self
830 }
831 pub fn apply_slice(self, data: &[f64]) -> Result<UiBatchOutput, UiError> {
832 ui_batch_with_kernel(data, &self.range, self.kernel)
833 }
834 pub fn with_default_slice(data: &[f64], k: Kernel) -> Result<UiBatchOutput, UiError> {
835 UiBatchBuilder::new().kernel(k).apply_slice(data)
836 }
837 pub fn apply_candles(self, c: &Candles, src: &str) -> Result<UiBatchOutput, UiError> {
838 let slice = source_type(c, src);
839 self.apply_slice(slice)
840 }
841 pub fn with_default_candles(c: &Candles) -> Result<UiBatchOutput, UiError> {
842 UiBatchBuilder::new()
843 .kernel(Kernel::Auto)
844 .apply_candles(c, "close")
845 }
846}
847
848pub fn ui_batch_with_kernel(
849 data: &[f64],
850 sweep: &UiBatchRange,
851 k: Kernel,
852) -> Result<UiBatchOutput, UiError> {
853 let kernel = match k {
854 Kernel::Auto => detect_best_batch_kernel(),
855 other if other.is_batch() => other,
856 other => {
857 return Err(UiError::InvalidKernelForBatch(other));
858 }
859 };
860
861 let simd = match kernel {
862 Kernel::Avx512Batch => Kernel::Avx512,
863 Kernel::Avx2Batch => Kernel::Avx2,
864 Kernel::ScalarBatch => Kernel::Scalar,
865 _ => Kernel::Scalar,
866 };
867 ui_batch_par_slice(data, sweep, simd)
868}
869
870#[derive(Clone, Debug)]
871pub struct UiBatchOutput {
872 pub values: Vec<f64>,
873 pub combos: Vec<UiParams>,
874 pub rows: usize,
875 pub cols: usize,
876}
877
878impl UiBatchOutput {
879 pub fn row_for_params(&self, p: &UiParams) -> Option<usize> {
880 self.combos.iter().position(|c| {
881 c.period.unwrap_or(14) == p.period.unwrap_or(14)
882 && (c.scalar.unwrap_or(100.0) - p.scalar.unwrap_or(100.0)).abs() < 1e-12
883 })
884 }
885 pub fn values_for(&self, p: &UiParams) -> Option<&[f64]> {
886 self.row_for_params(p).map(|row| {
887 let start = row * self.cols;
888 &self.values[start..start + self.cols]
889 })
890 }
891}
892
893#[inline(always)]
894fn expand_grid(r: &UiBatchRange) -> Result<Vec<UiParams>, UiError> {
895 fn axis_usize((start, end, step): (usize, usize, usize)) -> Result<Vec<usize>, UiError> {
896 if step == 0 || start == end {
897 return Ok(vec![start]);
898 }
899 if start < end {
900 let vals: Vec<usize> = (start..=end).step_by(step).collect();
901 if vals.is_empty() {
902 return Err(UiError::InvalidRange {
903 start: start as f64,
904 end: end as f64,
905 step: step as f64,
906 });
907 }
908 Ok(vals)
909 } else {
910 let mut v: Vec<usize> = (end..=start).step_by(step).collect();
911 if v.is_empty() {
912 return Err(UiError::InvalidRange {
913 start: start as f64,
914 end: end as f64,
915 step: step as f64,
916 });
917 }
918 v.reverse();
919 Ok(v)
920 }
921 }
922 fn axis_f64((start, end, step): (f64, f64, f64)) -> Result<Vec<f64>, UiError> {
923 if step.abs() < 1e-12 || (start - end).abs() < 1e-12 {
924 return Ok(vec![start]);
925 }
926
927 if (start < end && step <= 0.0) || (start > end && step >= 0.0) {
928 return Err(UiError::InvalidRange { start, end, step });
929 }
930 let mut v = Vec::new();
931 let mut x = start;
932 let max_iterations: usize = 10_000;
933 let mut iterations: usize = 0;
934 if start < end {
935 while x <= end + 1e-12 {
936 if iterations >= max_iterations {
937 return Err(UiError::InvalidRange { start, end, step });
938 }
939 v.push(x);
940 x += step;
941 iterations += 1;
942 }
943 } else {
944 while x >= end - 1e-12 {
945 if iterations >= max_iterations {
946 return Err(UiError::InvalidRange { start, end, step });
947 }
948 v.push(x);
949 x += step;
950 iterations += 1;
951 }
952 }
953 if v.is_empty() {
954 return Err(UiError::InvalidRange { start, end, step });
955 }
956 Ok(v)
957 }
958
959 let periods = axis_usize(r.period)?;
960 let scalars = axis_f64(r.scalar)?;
961 if periods.is_empty() || scalars.is_empty() {
962 return Err(UiError::InvalidRange {
963 start: r.period.0 as f64,
964 end: r.period.1 as f64,
965 step: r.period.2 as f64,
966 });
967 }
968 let combos_len = periods
969 .len()
970 .checked_mul(scalars.len())
971 .ok_or(UiError::InvalidRange {
972 start: periods.len() as f64,
973 end: scalars.len() as f64,
974 step: 0.0,
975 })?;
976 let mut out = Vec::with_capacity(combos_len);
977 for &p in &periods {
978 for &s in &scalars {
979 out.push(UiParams {
980 period: Some(p),
981 scalar: Some(s),
982 });
983 }
984 }
985 Ok(out)
986}
987
988#[inline(always)]
989pub fn ui_batch_slice(
990 data: &[f64],
991 sweep: &UiBatchRange,
992 kern: Kernel,
993) -> Result<UiBatchOutput, UiError> {
994 ui_batch_inner(data, sweep, kern, false)
995}
996
997#[inline(always)]
998pub fn ui_batch_par_slice(
999 data: &[f64],
1000 sweep: &UiBatchRange,
1001 kern: Kernel,
1002) -> Result<UiBatchOutput, UiError> {
1003 ui_batch_inner(data, sweep, kern, true)
1004}
1005
1006#[inline(always)]
1007fn ui_batch_inner(
1008 data: &[f64],
1009 sweep: &UiBatchRange,
1010 kern: Kernel,
1011 parallel: bool,
1012) -> Result<UiBatchOutput, UiError> {
1013 if data.is_empty() {
1014 return Err(UiError::EmptyInputData);
1015 }
1016
1017 let combos = expand_grid(sweep)?;
1018 if combos.is_empty() {
1019 return Err(UiError::InvalidRange {
1020 start: sweep.period.0 as f64,
1021 end: sweep.period.1 as f64,
1022 step: sweep.period.2 as f64,
1023 });
1024 }
1025
1026 let kern = match kern {
1027 Kernel::Auto => detect_best_kernel(),
1028 other => other,
1029 };
1030
1031 let first = data
1032 .iter()
1033 .position(|x| x.is_finite())
1034 .ok_or(UiError::AllValuesNaN)?;
1035 let max_p = combos.iter().map(|c| c.period.unwrap()).max().unwrap();
1036 let span =
1037 max_p
1038 .checked_mul(2)
1039 .and_then(|v| v.checked_sub(2))
1040 .ok_or(UiError::InvalidRange {
1041 start: max_p as f64,
1042 end: data.len() as f64,
1043 step: 2.0,
1044 })?;
1045 let max_warmup = first.checked_add(span).ok_or(UiError::InvalidRange {
1046 start: max_p as f64,
1047 end: data.len() as f64,
1048 step: 2.0,
1049 })?;
1050 if data.len() <= max_warmup {
1051 return Err(UiError::NotEnoughValidData {
1052 needed: max_warmup + 1,
1053 valid: data.len() - first,
1054 });
1055 }
1056
1057 let rows = combos.len();
1058 let cols = data.len();
1059 let _total = rows.checked_mul(cols).ok_or(UiError::InvalidRange {
1060 start: rows as f64,
1061 end: cols as f64,
1062 step: 0.0,
1063 })?;
1064 let mut buf_mu = make_uninit_matrix(rows, cols);
1065 let mut buf_guard = core::mem::ManuallyDrop::new(buf_mu);
1066 let out_slice: &mut [f64] = unsafe {
1067 core::slice::from_raw_parts_mut(buf_guard.as_mut_ptr() as *mut f64, buf_guard.len())
1068 };
1069
1070 let combos = ui_batch_inner_into(data, sweep, kern, parallel, out_slice)?;
1071
1072 let values_vec = unsafe {
1073 let ptr = buf_guard.as_mut_ptr() as *mut f64;
1074 let len = buf_guard.len();
1075 let cap = buf_guard.capacity();
1076 core::mem::forget(buf_guard);
1077 Vec::from_raw_parts(ptr, len, cap)
1078 };
1079
1080 Ok(UiBatchOutput {
1081 values: values_vec,
1082 combos,
1083 rows,
1084 cols,
1085 })
1086}
1087
1088#[inline(always)]
1089fn ui_row_scalar(data: &[f64], first: usize, period: usize, scalar: f64, out: &mut [f64]) {
1090 ui_scalar(data, period, scalar, first, out);
1091}
1092
1093#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1094#[inline(always)]
1095fn ui_row_avx2(data: &[f64], first: usize, period: usize, scalar: f64, out: &mut [f64]) {
1096 ui_row_scalar(data, first, period, scalar, out)
1097}
1098
1099#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1100#[inline(always)]
1101fn ui_row_avx512(data: &[f64], first: usize, period: usize, scalar: f64, out: &mut [f64]) {
1102 ui_row_scalar(data, first, period, scalar, out)
1103}
1104
1105#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1106#[inline(always)]
1107fn ui_row_avx512_short(data: &[f64], first: usize, period: usize, scalar: f64, out: &mut [f64]) {
1108 ui_row_scalar(data, first, period, scalar, out)
1109}
1110
1111#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1112#[inline(always)]
1113fn ui_row_avx512_long(data: &[f64], first: usize, period: usize, scalar: f64, out: &mut [f64]) {
1114 ui_row_scalar(data, first, period, scalar, out)
1115}
1116
1117#[cfg(feature = "python")]
1118#[pyfunction(name = "ui")]
1119#[pyo3(signature = (data, period, scalar=100.0, kernel=None))]
1120pub fn ui_py<'py>(
1121 py: Python<'py>,
1122 data: PyReadonlyArray1<'py, f64>,
1123 period: usize,
1124 scalar: f64,
1125 kernel: Option<&str>,
1126) -> PyResult<Bound<'py, PyArray1<f64>>> {
1127 use numpy::{IntoPyArray, PyArrayMethods};
1128
1129 let slice_in = data.as_slice()?;
1130 let kern = validate_kernel(kernel, false)?;
1131
1132 let params = UiParams {
1133 period: Some(period),
1134 scalar: Some(scalar),
1135 };
1136 let input = UiInput::from_slice(slice_in, params);
1137
1138 let result_vec: Vec<f64> = py
1139 .allow_threads(|| ui_with_kernel(&input, kern).map(|o| o.values))
1140 .map_err(|e| PyValueError::new_err(e.to_string()))?;
1141
1142 Ok(result_vec.into_pyarray(py))
1143}
1144
1145#[cfg(feature = "python")]
1146#[pyfunction(name = "ui_batch")]
1147#[pyo3(signature = (data, period_range, scalar_range=(100.0, 100.0, 0.0), kernel=None))]
1148pub fn ui_batch_py<'py>(
1149 py: Python<'py>,
1150 data: PyReadonlyArray1<'py, f64>,
1151 period_range: (usize, usize, usize),
1152 scalar_range: (f64, f64, f64),
1153 kernel: Option<&str>,
1154) -> PyResult<Bound<'py, PyDict>> {
1155 use numpy::{IntoPyArray, PyArray1, PyArrayMethods};
1156 use pyo3::types::PyDict;
1157
1158 let slice_in = data.as_slice()?;
1159
1160 let sweep = UiBatchRange {
1161 period: period_range,
1162 scalar: scalar_range,
1163 };
1164
1165 let combos = expand_grid(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
1166 let rows = combos.len();
1167 let cols = slice_in.len();
1168
1169 let total = rows
1170 .checked_mul(cols)
1171 .ok_or_else(|| PyValueError::new_err("ui_batch: rows * cols overflow"))?;
1172
1173 let out_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1174 let slice_out = unsafe { out_arr.as_slice_mut()? };
1175
1176 let kern = validate_kernel(kernel, true)?;
1177
1178 let combos = py
1179 .allow_threads(|| {
1180 let kernel = match kern {
1181 Kernel::Auto => detect_best_batch_kernel(),
1182 k => k,
1183 };
1184 let simd = match kernel {
1185 Kernel::Avx512Batch => Kernel::Avx512,
1186 Kernel::Avx2Batch => Kernel::Avx2,
1187 Kernel::ScalarBatch => Kernel::Scalar,
1188 _ => Kernel::Scalar,
1189 };
1190 ui_batch_inner_into(slice_in, &sweep, simd, true, slice_out)
1191 })
1192 .map_err(|e| PyValueError::new_err(e.to_string()))?;
1193
1194 let dict = PyDict::new(py);
1195 dict.set_item("values", out_arr.reshape((rows, cols))?)?;
1196 dict.set_item(
1197 "periods",
1198 combos
1199 .iter()
1200 .map(|p| p.period.unwrap() as u64)
1201 .collect::<Vec<_>>()
1202 .into_pyarray(py),
1203 )?;
1204 dict.set_item(
1205 "scalars",
1206 combos
1207 .iter()
1208 .map(|p| p.scalar.unwrap())
1209 .collect::<Vec<_>>()
1210 .into_pyarray(py),
1211 )?;
1212
1213 Ok(dict)
1214}
1215
1216#[cfg(feature = "python")]
1217#[pyclass(name = "UiStream")]
1218pub struct UiStreamPy {
1219 inner: UiStream,
1220}
1221
1222#[cfg(feature = "python")]
1223#[pymethods]
1224impl UiStreamPy {
1225 #[new]
1226 pub fn new(period: usize, scalar: f64) -> PyResult<Self> {
1227 let params = UiParams {
1228 period: Some(period),
1229 scalar: Some(scalar),
1230 };
1231 let inner = UiStream::try_new(params).map_err(|e| PyValueError::new_err(e.to_string()))?;
1232 Ok(UiStreamPy { inner })
1233 }
1234
1235 pub fn update(&mut self, value: f64) -> Option<f64> {
1236 self.inner.update(value)
1237 }
1238}
1239
1240#[inline(always)]
1241fn ui_batch_inner_into(
1242 data: &[f64],
1243 sweep: &UiBatchRange,
1244 kern: Kernel,
1245 parallel: bool,
1246 out: &mut [f64],
1247) -> Result<Vec<UiParams>, UiError> {
1248 if data.is_empty() {
1249 return Err(UiError::EmptyInputData);
1250 }
1251
1252 let combos = expand_grid(sweep)?;
1253 if combos.is_empty() {
1254 return Err(UiError::InvalidRange {
1255 start: sweep.period.0 as f64,
1256 end: sweep.period.1 as f64,
1257 step: sweep.period.2 as f64,
1258 });
1259 }
1260
1261 let first = data
1262 .iter()
1263 .position(|x| x.is_finite())
1264 .ok_or(UiError::AllValuesNaN)?;
1265 let max_p = combos.iter().map(|c| c.period.unwrap()).max().unwrap();
1266 let span =
1267 max_p
1268 .checked_mul(2)
1269 .and_then(|v| v.checked_sub(2))
1270 .ok_or(UiError::InvalidRange {
1271 start: max_p as f64,
1272 end: data.len() as f64,
1273 step: 2.0,
1274 })?;
1275 let max_warmup = first.checked_add(span).ok_or(UiError::InvalidRange {
1276 start: max_p as f64,
1277 end: data.len() as f64,
1278 step: 2.0,
1279 })?;
1280 if data.len() <= max_warmup {
1281 return Err(UiError::NotEnoughValidData {
1282 needed: max_warmup + 1,
1283 valid: data.len() - first,
1284 });
1285 }
1286
1287 let cols = data.len();
1288 for (row, combo) in combos.iter().enumerate() {
1289 let period = combo.period.unwrap();
1290 let span_row =
1291 period
1292 .checked_mul(2)
1293 .and_then(|v| v.checked_sub(2))
1294 .ok_or(UiError::InvalidRange {
1295 start: period as f64,
1296 end: data.len() as f64,
1297 step: 2.0,
1298 })?;
1299 let warmup = first.checked_add(span_row).ok_or(UiError::InvalidRange {
1300 start: period as f64,
1301 end: data.len() as f64,
1302 step: 2.0,
1303 })?;
1304 let row_start = row * cols;
1305 for i in 0..warmup.min(cols) {
1306 out[row_start + i] = f64::NAN;
1307 }
1308 }
1309
1310 use std::collections::BTreeMap;
1311 let mut by_period: BTreeMap<usize, Vec<(usize, f64)>> = BTreeMap::new();
1312 for (row, combo) in combos.iter().enumerate() {
1313 by_period
1314 .entry(combo.period.unwrap())
1315 .or_default()
1316 .push((row, combo.scalar.unwrap()));
1317 }
1318
1319 let mut process_group = |(period, rows): (&usize, &Vec<(usize, f64)>)| {
1320 let mut base = vec![f64::NAN; cols];
1321 match kern {
1322 Kernel::Scalar => ui_row_scalar(data, first, *period, 1.0, &mut base),
1323 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1324 Kernel::Avx2 => ui_row_avx2(data, first, *period, 1.0, &mut base),
1325 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1326 Kernel::Avx512 => ui_row_avx512(data, first, *period, 1.0, &mut base),
1327 _ => ui_row_scalar(data, first, *period, 1.0, &mut base),
1328 }
1329
1330 for &(row, scalar) in rows.iter() {
1331 let s = scalar.abs();
1332 let row_start = row * cols;
1333 let dst = &mut out[row_start..row_start + cols];
1334 for i in 0..cols {
1335 let v = base[i];
1336 dst[i] = if v.is_finite() { v * s } else { v };
1337 }
1338 }
1339 };
1340
1341 if parallel {
1342 #[cfg(not(target_arch = "wasm32"))]
1343 {
1344 use rayon::prelude::*;
1345 use std::collections::HashMap;
1346 use std::sync::Arc;
1347
1348 let period_keys: Vec<usize> = by_period.keys().copied().collect();
1349 let base_map: HashMap<usize, Arc<Vec<f64>>> = period_keys
1350 .par_iter()
1351 .map(|&p| {
1352 let mut base = vec![f64::NAN; cols];
1353 match kern {
1354 Kernel::Scalar => ui_row_scalar(data, first, p, 1.0, &mut base),
1355 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1356 Kernel::Avx2 => ui_row_avx2(data, first, p, 1.0, &mut base),
1357 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1358 Kernel::Avx512 => ui_row_avx512(data, first, p, 1.0, &mut base),
1359 _ => ui_row_scalar(data, first, p, 1.0, &mut base),
1360 }
1361 (p, Arc::new(base))
1362 })
1363 .collect();
1364
1365 out.par_chunks_mut(cols)
1366 .enumerate()
1367 .for_each(|(row, slice)| {
1368 let p = combos[row].period.unwrap();
1369 let s = combos[row].scalar.unwrap().abs();
1370 let base = base_map.get(&p).expect("base series present");
1371
1372 for i in 0..cols {
1373 let v = base[i];
1374 slice[i] = if v.is_finite() { v * s } else { v };
1375 }
1376 });
1377 }
1378
1379 #[cfg(target_arch = "wasm32")]
1380 {
1381 for entry in by_period.iter() {
1382 process_group(entry);
1383 }
1384 }
1385 } else {
1386 for entry in by_period.iter() {
1387 process_group(entry);
1388 }
1389 }
1390
1391 Ok(combos)
1392}
1393
1394#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1395use wasm_bindgen::prelude::*;
1396
1397#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1398pub fn ui_into_slice(dst: &mut [f64], input: &UiInput, kern: Kernel) -> Result<(), UiError> {
1399 let data: &[f64] = input.as_ref();
1400 let len = data.len();
1401 if len == 0 {
1402 return Err(UiError::EmptyInputData);
1403 }
1404
1405 if dst.len() != len {
1406 return Err(UiError::OutputLengthMismatch {
1407 expected: len,
1408 got: dst.len(),
1409 });
1410 }
1411
1412 let period = input.get_period();
1413 let scalar = input.get_scalar();
1414 if period == 0 || period > len {
1415 return Err(UiError::InvalidPeriod {
1416 period,
1417 data_len: len,
1418 });
1419 }
1420 if !scalar.is_finite() {
1421 return Err(UiError::InvalidScalar { scalar });
1422 }
1423
1424 let first = data
1425 .iter()
1426 .position(|x| x.is_finite())
1427 .ok_or(UiError::AllValuesNaN)?;
1428 if (len - first) < period {
1429 return Err(UiError::NotEnoughValidData {
1430 needed: period,
1431 valid: len - first,
1432 });
1433 }
1434
1435 let chosen = match kern {
1436 Kernel::Auto => detect_best_kernel(),
1437 other => other,
1438 };
1439
1440 let warmup = first + (period * 2 - 2);
1441 for v in &mut dst[..warmup.min(len)] {
1442 *v = f64::NAN;
1443 }
1444
1445 match chosen {
1446 Kernel::Scalar | Kernel::ScalarBatch => ui_scalar(data, period, scalar, first, dst),
1447 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1448 Kernel::Avx2 | Kernel::Avx2Batch => ui_avx2(data, period, scalar, first, dst),
1449 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1450 Kernel::Avx512 | Kernel::Avx512Batch => ui_avx512(data, period, scalar, first, dst),
1451 _ => ui_scalar(data, period, scalar, first, dst),
1452 }
1453 Ok(())
1454}
1455
1456#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1457#[wasm_bindgen]
1458pub fn ui_js(data: &[f64], period: usize, scalar: f64) -> Result<Vec<f64>, JsValue> {
1459 if !scalar.is_finite() {
1460 return Err(JsValue::from_str(&format!("Invalid scalar: {}", scalar)));
1461 }
1462 let params = UiParams {
1463 period: Some(period),
1464 scalar: Some(scalar),
1465 };
1466 let input = UiInput::from_slice(data, params);
1467
1468 let mut output = vec![0.0; data.len()];
1469
1470 ui_into_slice(&mut output, &input, detect_best_kernel())
1471 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1472
1473 Ok(output)
1474}
1475
1476#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1477#[wasm_bindgen]
1478pub fn ui_into(
1479 in_ptr: *const f64,
1480 out_ptr: *mut f64,
1481 len: usize,
1482 period: usize,
1483 scalar: f64,
1484) -> Result<(), JsValue> {
1485 if in_ptr.is_null() || out_ptr.is_null() {
1486 return Err(JsValue::from_str("Null pointer provided"));
1487 }
1488 if !scalar.is_finite() {
1489 return Err(JsValue::from_str(&format!("Invalid scalar: {}", scalar)));
1490 }
1491
1492 unsafe {
1493 let data = std::slice::from_raw_parts(in_ptr, len);
1494 let params = UiParams {
1495 period: Some(period),
1496 scalar: Some(scalar),
1497 };
1498 let input = UiInput::from_slice(data, params);
1499
1500 if in_ptr == out_ptr.cast_const() {
1501 let mut temp = vec![0.0; len];
1502 ui_into_slice(&mut temp, &input, detect_best_kernel())
1503 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1504 let out = std::slice::from_raw_parts_mut(out_ptr, len);
1505 out.copy_from_slice(&temp);
1506 } else {
1507 let out = std::slice::from_raw_parts_mut(out_ptr, len);
1508 ui_into_slice(out, &input, detect_best_kernel())
1509 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1510 }
1511 Ok(())
1512 }
1513}
1514
1515#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1516#[wasm_bindgen]
1517pub fn ui_alloc(len: usize) -> *mut f64 {
1518 let mut vec = Vec::<f64>::with_capacity(len);
1519 let ptr = vec.as_mut_ptr();
1520 std::mem::forget(vec);
1521 ptr
1522}
1523
1524#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1525#[wasm_bindgen]
1526pub fn ui_free(ptr: *mut f64, len: usize) {
1527 if !ptr.is_null() {
1528 unsafe {
1529 let _ = Vec::from_raw_parts(ptr, len, len);
1530 }
1531 }
1532}
1533
1534#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1535#[derive(Serialize, Deserialize)]
1536pub struct UiBatchConfig {
1537 pub period_range: (usize, usize, usize),
1538 pub scalar_range: (f64, f64, f64),
1539}
1540
1541#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1542#[derive(Serialize, Deserialize)]
1543pub struct UiBatchJsOutput {
1544 pub values: Vec<f64>,
1545 pub combos: Vec<UiParams>,
1546 pub rows: usize,
1547 pub cols: usize,
1548}
1549
1550#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1551#[wasm_bindgen(js_name = ui_batch)]
1552pub fn ui_batch_unified_js(data: &[f64], config: JsValue) -> Result<JsValue, JsValue> {
1553 let config: UiBatchConfig = serde_wasm_bindgen::from_value(config)
1554 .map_err(|e| JsValue::from_str(&format!("Invalid config: {}", e)))?;
1555
1556 let sweep = UiBatchRange {
1557 period: config.period_range,
1558 scalar: config.scalar_range,
1559 };
1560
1561 let output = ui_batch_inner(data, &sweep, detect_best_kernel(), false)
1562 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1563
1564 let js_output = UiBatchJsOutput {
1565 values: output.values,
1566 combos: output.combos,
1567 rows: output.rows,
1568 cols: output.cols,
1569 };
1570
1571 serde_wasm_bindgen::to_value(&js_output)
1572 .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
1573}
1574
1575#[cfg(all(feature = "python", feature = "cuda"))]
1576#[pyfunction(name = "ui_cuda_batch_dev")]
1577#[pyo3(signature = (data_f32, period_range, scalar_range=(100.0, 100.0, 0.0), device_id=0))]
1578pub fn ui_cuda_batch_dev_py(
1579 py: Python<'_>,
1580 data_f32: numpy::PyReadonlyArray1<'_, f32>,
1581 period_range: (usize, usize, usize),
1582 scalar_range: (f64, f64, f64),
1583 device_id: usize,
1584) -> PyResult<DeviceArrayF32Py> {
1585 use crate::cuda::cuda_available;
1586 if !cuda_available() {
1587 return Err(PyValueError::new_err("CUDA not available"));
1588 }
1589 let slice_in = data_f32.as_slice()?;
1590 let sweep = UiBatchRange {
1591 period: period_range,
1592 scalar: scalar_range,
1593 };
1594 let inner = py.allow_threads(|| {
1595 let cuda = CudaUi::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
1596 cuda.ui_batch_dev(slice_in, &sweep)
1597 .map(|(arr, _)| arr)
1598 .map_err(|e| PyValueError::new_err(e.to_string()))
1599 })?;
1600 make_device_array_py(device_id, inner)
1601}
1602
1603#[cfg(all(feature = "python", feature = "cuda"))]
1604#[pyfunction(name = "ui_cuda_many_series_one_param_dev")]
1605#[pyo3(signature = (data_tm_f32, period, scalar=100.0, device_id=0))]
1606pub fn ui_cuda_many_series_one_param_dev_py(
1607 py: Python<'_>,
1608 data_tm_f32: numpy::PyReadonlyArray2<'_, f32>,
1609 period: usize,
1610 scalar: f64,
1611 device_id: usize,
1612) -> PyResult<DeviceArrayF32Py> {
1613 use crate::cuda::cuda_available;
1614 use numpy::PyUntypedArrayMethods;
1615 if !cuda_available() {
1616 return Err(PyValueError::new_err("CUDA not available"));
1617 }
1618 let flat_in: &[f32] = data_tm_f32.as_slice()?;
1619 let rows = data_tm_f32.shape()[0];
1620 let cols = data_tm_f32.shape()[1];
1621 let params = UiParams {
1622 period: Some(period),
1623 scalar: Some(scalar),
1624 };
1625 let inner = py.allow_threads(|| {
1626 let cuda = CudaUi::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
1627 cuda.ui_many_series_one_param_time_major_dev(flat_in, cols, rows, ¶ms)
1628 .map_err(|e| PyValueError::new_err(e.to_string()))
1629 })?;
1630 make_device_array_py(device_id, inner)
1631}
1632
1633#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1634#[wasm_bindgen]
1635pub fn ui_batch_into(
1636 in_ptr: *const f64,
1637 out_ptr: *mut f64,
1638 len: usize,
1639 period_start: usize,
1640 period_end: usize,
1641 period_step: usize,
1642 scalar_start: f64,
1643 scalar_end: f64,
1644 scalar_step: f64,
1645) -> Result<usize, JsValue> {
1646 if in_ptr.is_null() || out_ptr.is_null() {
1647 return Err(JsValue::from_str("null pointer passed to ui_batch_into"));
1648 }
1649
1650 unsafe {
1651 let data = std::slice::from_raw_parts(in_ptr, len);
1652
1653 let sweep = UiBatchRange {
1654 period: (period_start, period_end, period_step),
1655 scalar: (scalar_start, scalar_end, scalar_step),
1656 };
1657
1658 let combos = expand_grid(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
1659 let rows = combos.len();
1660 let cols = len;
1661
1662 let total = rows
1663 .checked_mul(cols)
1664 .ok_or_else(|| JsValue::from_str("ui_batch_into: rows * cols overflow"))?;
1665
1666 let out = std::slice::from_raw_parts_mut(out_ptr, total);
1667
1668 ui_batch_inner_into(data, &sweep, detect_best_kernel(), false, out)
1669 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1670
1671 Ok(rows)
1672 }
1673}
1674
1675#[cfg(test)]
1676mod tests {
1677 use super::*;
1678 use crate::skip_if_unsupported;
1679 use crate::utilities::data_loader::read_candles_from_csv;
1680
1681 fn check_ui_partial_params(
1682 test_name: &str,
1683 kernel: Kernel,
1684 ) -> Result<(), Box<dyn std::error::Error>> {
1685 skip_if_unsupported!(kernel, test_name);
1686 let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1687 let candles = read_candles_from_csv(file_path)?;
1688 let default_params = UiParams {
1689 period: None,
1690 scalar: None,
1691 };
1692 let input = UiInput::from_candles(&candles, "close", default_params);
1693 let output = ui_with_kernel(&input, kernel)?;
1694 assert_eq!(output.values.len(), candles.close.len());
1695 Ok(())
1696 }
1697
1698 fn check_ui_accuracy(
1699 test_name: &str,
1700 kernel: Kernel,
1701 ) -> Result<(), Box<dyn std::error::Error>> {
1702 skip_if_unsupported!(kernel, test_name);
1703 let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1704 let candles = read_candles_from_csv(file_path)?;
1705 let params = UiParams {
1706 period: Some(14),
1707 scalar: Some(100.0),
1708 };
1709 let input = UiInput::from_candles(&candles, "close", params);
1710 let ui_result = ui_with_kernel(&input, kernel)?;
1711 let expected_last_five_ui = [
1712 3.514342861283708,
1713 3.304986039846459,
1714 3.2011859814326304,
1715 3.1308860017483373,
1716 2.909612553474519,
1717 ];
1718 assert!(ui_result.values.len() >= 5);
1719 let start_index = ui_result.values.len() - 5;
1720 let result_last_five_ui = &ui_result.values[start_index..];
1721 for (i, &value) in result_last_five_ui.iter().enumerate() {
1722 let expected_value = expected_last_five_ui[i];
1723 assert!(
1724 (value - expected_value).abs() < 1e-6,
1725 "[{}] UI mismatch at index {}: expected {}, got {}",
1726 test_name,
1727 i,
1728 expected_value,
1729 value
1730 );
1731 }
1732 let period = 14;
1733 for i in 0..(period - 1) {
1734 assert!(ui_result.values[i].is_nan());
1735 }
1736 Ok(())
1737 }
1738
1739 fn check_ui_default_candles(
1740 test_name: &str,
1741 kernel: Kernel,
1742 ) -> Result<(), Box<dyn std::error::Error>> {
1743 skip_if_unsupported!(kernel, test_name);
1744 let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1745 let candles = read_candles_from_csv(file_path)?;
1746 let input = UiInput::with_default_candles(&candles);
1747 match input.data {
1748 UiData::Candles { source, .. } => assert_eq!(source, "close"),
1749 _ => panic!("Expected UiData::Candles"),
1750 }
1751 let output = ui_with_kernel(&input, kernel)?;
1752 assert_eq!(output.values.len(), candles.close.len());
1753 Ok(())
1754 }
1755
1756 fn check_ui_zero_period(
1757 test_name: &str,
1758 kernel: Kernel,
1759 ) -> Result<(), Box<dyn std::error::Error>> {
1760 skip_if_unsupported!(kernel, test_name);
1761 let input_data = [10.0, 20.0, 30.0];
1762 let params = UiParams {
1763 period: Some(0),
1764 scalar: None,
1765 };
1766 let input = UiInput::from_slice(&input_data, params);
1767 let res = ui_with_kernel(&input, kernel);
1768 assert!(res.is_err());
1769 Ok(())
1770 }
1771
1772 fn check_ui_period_exceeds_length(
1773 test_name: &str,
1774 kernel: Kernel,
1775 ) -> Result<(), Box<dyn std::error::Error>> {
1776 skip_if_unsupported!(kernel, test_name);
1777 let data_small = [10.0, 20.0, 30.0];
1778 let params = UiParams {
1779 period: Some(10),
1780 scalar: None,
1781 };
1782 let input = UiInput::from_slice(&data_small, params);
1783 let res = ui_with_kernel(&input, kernel);
1784 assert!(res.is_err());
1785 Ok(())
1786 }
1787
1788 fn check_ui_very_small_dataset(
1789 test_name: &str,
1790 kernel: Kernel,
1791 ) -> Result<(), Box<dyn std::error::Error>> {
1792 skip_if_unsupported!(kernel, test_name);
1793 let single_point = [42.0];
1794 let params = UiParams {
1795 period: Some(14),
1796 scalar: Some(100.0),
1797 };
1798 let input = UiInput::from_slice(&single_point, params);
1799 let res = ui_with_kernel(&input, kernel);
1800 assert!(res.is_err());
1801 Ok(())
1802 }
1803
1804 #[cfg(debug_assertions)]
1805 fn check_ui_no_poison(
1806 test_name: &str,
1807 kernel: Kernel,
1808 ) -> Result<(), Box<dyn std::error::Error>> {
1809 skip_if_unsupported!(kernel, test_name);
1810
1811 let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1812 let candles = read_candles_from_csv(file_path)?;
1813
1814 let test_params = vec![
1815 UiParams::default(),
1816 UiParams {
1817 period: Some(2),
1818 scalar: Some(100.0),
1819 },
1820 UiParams {
1821 period: Some(5),
1822 scalar: Some(50.0),
1823 },
1824 UiParams {
1825 period: Some(10),
1826 scalar: Some(100.0),
1827 },
1828 UiParams {
1829 period: Some(20),
1830 scalar: Some(200.0),
1831 },
1832 UiParams {
1833 period: Some(50),
1834 scalar: Some(100.0),
1835 },
1836 UiParams {
1837 period: Some(100),
1838 scalar: Some(100.0),
1839 },
1840 UiParams {
1841 period: Some(14),
1842 scalar: Some(1.0),
1843 },
1844 UiParams {
1845 period: Some(14),
1846 scalar: Some(500.0),
1847 },
1848 UiParams {
1849 period: Some(14),
1850 scalar: Some(1000.0),
1851 },
1852 UiParams {
1853 period: Some(7),
1854 scalar: Some(75.0),
1855 },
1856 UiParams {
1857 period: Some(30),
1858 scalar: Some(150.0),
1859 },
1860 ];
1861
1862 for (param_idx, params) in test_params.iter().enumerate() {
1863 let input = UiInput::from_candles(&candles, "close", params.clone());
1864 let output = ui_with_kernel(&input, kernel)?;
1865
1866 for (i, &val) in output.values.iter().enumerate() {
1867 if val.is_nan() {
1868 continue;
1869 }
1870
1871 let bits = val.to_bits();
1872
1873 if bits == 0x11111111_11111111 {
1874 panic!(
1875 "[{}] Found alloc_with_nan_prefix poison value {} (0x{:016X}) at index {} \
1876 with params: period={}, scalar={} (param set {})",
1877 test_name,
1878 val,
1879 bits,
1880 i,
1881 params.period.unwrap_or(14),
1882 params.scalar.unwrap_or(100.0),
1883 param_idx
1884 );
1885 }
1886
1887 if bits == 0x22222222_22222222 {
1888 panic!(
1889 "[{}] Found init_matrix_prefixes poison value {} (0x{:016X}) at index {} \
1890 with params: period={}, scalar={} (param set {})",
1891 test_name,
1892 val,
1893 bits,
1894 i,
1895 params.period.unwrap_or(14),
1896 params.scalar.unwrap_or(100.0),
1897 param_idx
1898 );
1899 }
1900
1901 if bits == 0x33333333_33333333 {
1902 panic!(
1903 "[{}] Found make_uninit_matrix poison value {} (0x{:016X}) at index {} \
1904 with params: period={}, scalar={} (param set {})",
1905 test_name,
1906 val,
1907 bits,
1908 i,
1909 params.period.unwrap_or(14),
1910 params.scalar.unwrap_or(100.0),
1911 param_idx
1912 );
1913 }
1914 }
1915 }
1916
1917 Ok(())
1918 }
1919
1920 #[cfg(not(debug_assertions))]
1921 fn check_ui_no_poison(
1922 _test_name: &str,
1923 _kernel: Kernel,
1924 ) -> Result<(), Box<dyn std::error::Error>> {
1925 Ok(())
1926 }
1927
1928 macro_rules! generate_all_ui_tests {
1929 ($($test_fn:ident),*) => {
1930 paste::paste! {
1931 $(
1932 #[test]
1933 fn [<$test_fn _scalar_f64>]() {
1934 let _ = $test_fn(stringify!([<$test_fn _scalar_f64>]), Kernel::Scalar);
1935 }
1936 )*
1937 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1938 $(
1939 #[test]
1940 fn [<$test_fn _avx2_f64>]() {
1941 let _ = $test_fn(stringify!([<$test_fn _avx2_f64>]), Kernel::Avx2);
1942 }
1943 #[test]
1944 fn [<$test_fn _avx512_f64>]() {
1945 let _ = $test_fn(stringify!([<$test_fn _avx512_f64>]), Kernel::Avx512);
1946 }
1947 )*
1948 }
1949 }
1950 }
1951
1952 #[cfg(feature = "proptest")]
1953 #[allow(clippy::float_cmp)]
1954 fn check_ui_property(
1955 test_name: &str,
1956 kernel: Kernel,
1957 ) -> Result<(), Box<dyn std::error::Error>> {
1958 use proptest::prelude::*;
1959 skip_if_unsupported!(kernel, test_name);
1960
1961 let strat = (2usize..=20, 1.0f64..200.0f64).prop_flat_map(|(period, scalar)| {
1962 let min_data_needed = period * 2 - 2 + 20;
1963 (
1964 prop::collection::vec(
1965 (0.001f64..1e6f64)
1966 .prop_filter("positive finite", |x| x.is_finite() && *x > 0.0),
1967 min_data_needed..400,
1968 ),
1969 Just(period),
1970 Just(scalar),
1971 )
1972 });
1973
1974 proptest::test_runner::TestRunner::default()
1975 .run(&strat, |(data, period, scalar)| {
1976 let params = UiParams {
1977 period: Some(period),
1978 scalar: Some(scalar),
1979 };
1980 let input = UiInput::from_slice(&data, params);
1981
1982 let UiOutput { values: out } = ui_with_kernel(&input, kernel).unwrap();
1983 let UiOutput { values: ref_out } = ui_with_kernel(&input, Kernel::Scalar).unwrap();
1984
1985 let warmup_period = period * 2 - 2;
1986 for i in 0..warmup_period.min(data.len()) {
1987 prop_assert!(
1988 out[i].is_nan(),
1989 "[{}] Expected NaN during warmup at index {}, got {}",
1990 test_name,
1991 i,
1992 out[i]
1993 );
1994 }
1995
1996 for (i, &value) in out.iter().enumerate() {
1997 if !value.is_nan() {
1998 prop_assert!(
1999 value >= 0.0,
2000 "[{}] UI must be non-negative at index {}: got {}",
2001 test_name,
2002 i,
2003 value
2004 );
2005 }
2006 }
2007
2008 let is_monotonic_increase = data.windows(2).all(|w| w[1] >= w[0]);
2009 if is_monotonic_increase && data.len() > warmup_period {
2010 for i in warmup_period..data.len() {
2011 prop_assert!(
2012 out[i].abs() < 1e-9,
2013 "[{}] UI should be ~0 for monotonic increase at index {}: got {}",
2014 test_name,
2015 i,
2016 out[i]
2017 );
2018 }
2019 }
2020
2021 if period == 1 {
2022 for (i, &value) in out.iter().enumerate() {
2023 prop_assert!(
2024 value.abs() < 1e-9,
2025 "[{}] UI with period=1 should be 0 at index {}: got {}",
2026 test_name,
2027 i,
2028 value
2029 );
2030 }
2031 }
2032
2033 let is_flat = data.windows(2).all(|w| (w[0] - w[1]).abs() < 1e-12);
2034 if is_flat && data.len() > warmup_period {
2035 for i in warmup_period..data.len() {
2036 prop_assert!(
2037 out[i].abs() < 1e-9,
2038 "[{}] UI should be 0 for flat data at index {}: got {}",
2039 test_name,
2040 i,
2041 out[i]
2042 );
2043 }
2044 }
2045
2046 for i in warmup_period..data.len() {
2047 if !out[i].is_nan() {
2048 prop_assert!(
2049 out[i] <= scalar * 1.1,
2050 "[{}] UI exceeds theoretical maximum at index {}: UI={}, max={}",
2051 test_name,
2052 i,
2053 out[i],
2054 scalar * 1.1
2055 );
2056
2057 prop_assert!(
2058 out[i].is_finite(),
2059 "[{}] UI is not finite at index {}: {}",
2060 test_name,
2061 i,
2062 out[i]
2063 );
2064 }
2065 }
2066
2067 for i in 0..data.len() {
2068 let y = out[i];
2069 let r = ref_out[i];
2070
2071 if !y.is_finite() || !r.is_finite() {
2072 prop_assert!(
2073 y.to_bits() == r.to_bits(),
2074 "[{}] finite/NaN mismatch at index {}: {} vs {}",
2075 test_name,
2076 i,
2077 y,
2078 r
2079 );
2080 continue;
2081 }
2082
2083 let ulp_diff: u64 = y.to_bits().abs_diff(r.to_bits());
2084 prop_assert!(
2085 (y - r).abs() <= 1e-9 || ulp_diff <= 4,
2086 "[{}] kernel mismatch at index {}: {} vs {} (ULP={})",
2087 test_name,
2088 i,
2089 y,
2090 r,
2091 ulp_diff
2092 );
2093 }
2094
2095 let UiOutput { values: out2 } = ui_with_kernel(&input, kernel).unwrap();
2096 for i in 0..data.len() {
2097 if out[i].is_finite() && out2[i].is_finite() {
2098 prop_assert!(
2099 (out[i] - out2[i]).abs() < 1e-12,
2100 "[{}] Non-deterministic result at index {}: {} vs {}",
2101 test_name,
2102 i,
2103 out[i],
2104 out2[i]
2105 );
2106 } else {
2107 prop_assert!(
2108 out[i].to_bits() == out2[i].to_bits(),
2109 "[{}] Non-deterministic NaN at index {}",
2110 test_name,
2111 i
2112 );
2113 }
2114 }
2115
2116 if scalar > 1.0 && scalar < 100.0 {
2117 let params2 = UiParams {
2118 period: Some(period),
2119 scalar: Some(scalar * 2.0),
2120 };
2121 let input2 = UiInput::from_slice(&data, params2);
2122 let UiOutput { values: out_scaled } = ui_with_kernel(&input2, kernel).unwrap();
2123
2124 for i in warmup_period..data.len() {
2125 if out[i].is_finite() && out_scaled[i].is_finite() && out[i] > 1e-9 {
2126 let ratio = out_scaled[i] / out[i];
2127 prop_assert!(
2128 (ratio - 2.0).abs() < 1e-6,
2129 "[{}] Scalar not proportional at index {}: ratio={} (expected 2.0)",
2130 test_name,
2131 i,
2132 ratio
2133 );
2134 }
2135 }
2136 }
2137
2138 let has_large_stable_region =
2139 data.len() > period * 4 && data.iter().all(|&x| x > 0.1 && x < 1e5);
2140 if has_large_stable_region {
2141 let valid_count = out[warmup_period..]
2142 .iter()
2143 .filter(|&&x| !x.is_nan())
2144 .count();
2145 let expected_valid = data.len() - warmup_period;
2146
2147 prop_assert!(
2148 valid_count as f64 >= expected_valid as f64 * 0.8,
2149 "[{}] Too few valid outputs: {} out of {} expected",
2150 test_name,
2151 valid_count,
2152 expected_valid
2153 );
2154 }
2155
2156 if data.len() > period * 4 {
2157 let mut min_volatility_ui = f64::INFINITY;
2158 let mut max_volatility_ui = 0.0;
2159
2160 for i in warmup_period..data.len() {
2161 if !out[i].is_nan() {
2162 let window_start = i.saturating_sub(period - 1);
2163 let window = &data[window_start..=i];
2164 let max_price =
2165 window.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
2166 let min_price = window.iter().cloned().fold(f64::INFINITY, f64::min);
2167 let price_range = (max_price - min_price) / max_price;
2168
2169 if price_range < 0.01 && out[i] < min_volatility_ui {
2170 min_volatility_ui = out[i];
2171 }
2172 if price_range > 0.1 && out[i] > max_volatility_ui {
2173 max_volatility_ui = out[i];
2174 }
2175 }
2176 }
2177
2178 if min_volatility_ui != f64::INFINITY && max_volatility_ui > 0.0 {
2179 prop_assert!(
2180 max_volatility_ui >= min_volatility_ui,
2181 "[{}] UI should be higher for volatile periods: low_vol_UI={}, high_vol_UI={}",
2182 test_name, min_volatility_ui, max_volatility_ui
2183 );
2184 }
2185 }
2186
2187 if period <= 5 && data.len() > warmup_period + period {
2188 for i in (warmup_period + period)..data.len().min(warmup_period + period * 2) {
2189 if !out[i].is_nan() && out[i] > scalar * 0.01 {
2190 let mut sum_squared_dd = 0.0;
2191 let mut valid_count = 0;
2192
2193 for j in 0..period {
2194 let pos = i - j;
2195 if pos >= period - 1 {
2196 let max_start = pos + 1 - period;
2197 let max_end = pos + 1;
2198 let rolling_max = data[max_start..max_end]
2199 .iter()
2200 .cloned()
2201 .fold(f64::NEG_INFINITY, f64::max);
2202
2203 if rolling_max > 0.0 && !data[pos].is_nan() {
2204 let dd = scalar * (data[pos] - rolling_max) / rolling_max;
2205 sum_squared_dd += dd * dd;
2206 valid_count += 1;
2207 }
2208 }
2209 }
2210
2211 if valid_count == period {
2212 let manual_ui = (sum_squared_dd / period as f64).sqrt();
2213
2214 let tolerance = manual_ui * 0.05 + 1e-6;
2215 prop_assert!(
2216 (out[i] - manual_ui).abs() <= tolerance,
2217 "[{}] Direct formula verification failed at index {}: calculated={}, expected={}, diff={}",
2218 test_name, i, out[i], manual_ui, (out[i] - manual_ui).abs()
2219 );
2220 break;
2221 }
2222 }
2223 }
2224 }
2225
2226 let has_low_volatility =
2227 data.windows(2).all(|w| (w[1] - w[0]).abs() / w[0] < 0.0001);
2228 if has_low_volatility && data.len() > warmup_period {
2229 for i in warmup_period..data.len() {
2230 if !out[i].is_nan() {
2231 prop_assert!(
2232 out[i] < scalar * 0.01,
2233 "[{}] UI too high for near-zero volatility at index {}: UI={}",
2234 test_name,
2235 i,
2236 out[i]
2237 );
2238 }
2239 }
2240 }
2241
2242 Ok(())
2243 })
2244 .unwrap();
2245
2246 Ok(())
2247 }
2248
2249 generate_all_ui_tests!(
2250 check_ui_partial_params,
2251 check_ui_accuracy,
2252 check_ui_default_candles,
2253 check_ui_zero_period,
2254 check_ui_period_exceeds_length,
2255 check_ui_very_small_dataset,
2256 check_ui_no_poison
2257 );
2258
2259 #[cfg(feature = "proptest")]
2260 generate_all_ui_tests!(check_ui_property);
2261
2262 fn check_batch_default_row(
2263 test: &str,
2264 kernel: Kernel,
2265 ) -> Result<(), Box<dyn std::error::Error>> {
2266 skip_if_unsupported!(kernel, test);
2267
2268 let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
2269 let c = read_candles_from_csv(file)?;
2270
2271 let output = UiBatchBuilder::new()
2272 .kernel(kernel)
2273 .apply_candles(&c, "close")?;
2274
2275 let def = UiParams::default();
2276 let row = output.values_for(&def).expect("default row missing");
2277
2278 assert_eq!(row.len(), c.close.len());
2279
2280 let expected = [
2281 3.514342861283708,
2282 3.304986039846459,
2283 3.2011859814326304,
2284 3.1308860017483373,
2285 2.909612553474519,
2286 ];
2287 let start = row.len() - 5;
2288 for (i, &v) in row[start..].iter().enumerate() {
2289 assert!(
2290 (v - expected[i]).abs() < 1e-6,
2291 "[{test}] default-row mismatch at idx {i}: {v} vs {expected:?}"
2292 );
2293 }
2294 Ok(())
2295 }
2296
2297 #[cfg(debug_assertions)]
2298 fn check_batch_no_poison(test: &str, kernel: Kernel) -> Result<(), Box<dyn std::error::Error>> {
2299 skip_if_unsupported!(kernel, test);
2300
2301 let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
2302 let c = read_candles_from_csv(file)?;
2303
2304 let test_configs = vec![
2305 (2, 10, 2, 100.0, 100.0, 0.0),
2306 (5, 25, 5, 50.0, 150.0, 50.0),
2307 (30, 60, 15, 100.0, 100.0, 0.0),
2308 (2, 5, 1, 1.0, 100.0, 33.0),
2309 (10, 20, 2, 200.0, 200.0, 0.0),
2310 (14, 14, 0, 1.0, 1000.0, 199.0),
2311 (3, 12, 3, 75.0, 125.0, 25.0),
2312 (50, 100, 25, 100.0, 500.0, 200.0),
2313 (7, 21, 7, 50.0, 50.0, 0.0),
2314 ];
2315
2316 for (cfg_idx, &(p_start, p_end, p_step, s_start, s_end, s_step)) in
2317 test_configs.iter().enumerate()
2318 {
2319 let output = UiBatchBuilder::new()
2320 .kernel(kernel)
2321 .period_range(p_start, p_end, p_step)
2322 .scalar_range(s_start, s_end, s_step)
2323 .apply_candles(&c, "close")?;
2324
2325 for (idx, &val) in output.values.iter().enumerate() {
2326 if val.is_nan() {
2327 continue;
2328 }
2329
2330 let bits = val.to_bits();
2331 let row = idx / output.cols;
2332 let col = idx % output.cols;
2333 let combo = &output.combos[row];
2334
2335 if bits == 0x11111111_11111111 {
2336 panic!(
2337 "[{}] Config {}: Found alloc_with_nan_prefix poison value {} (0x{:016X}) \
2338 at row {} col {} (flat index {}) with params: period={}, scalar={}",
2339 test,
2340 cfg_idx,
2341 val,
2342 bits,
2343 row,
2344 col,
2345 idx,
2346 combo.period.unwrap_or(14),
2347 combo.scalar.unwrap_or(100.0)
2348 );
2349 }
2350
2351 if bits == 0x22222222_22222222 {
2352 panic!(
2353 "[{}] Config {}: Found init_matrix_prefixes poison value {} (0x{:016X}) \
2354 at row {} col {} (flat index {}) with params: period={}, scalar={}",
2355 test,
2356 cfg_idx,
2357 val,
2358 bits,
2359 row,
2360 col,
2361 idx,
2362 combo.period.unwrap_or(14),
2363 combo.scalar.unwrap_or(100.0)
2364 );
2365 }
2366
2367 if bits == 0x33333333_33333333 {
2368 panic!(
2369 "[{}] Config {}: Found make_uninit_matrix poison value {} (0x{:016X}) \
2370 at row {} col {} (flat index {}) with params: period={}, scalar={}",
2371 test,
2372 cfg_idx,
2373 val,
2374 bits,
2375 row,
2376 col,
2377 idx,
2378 combo.period.unwrap_or(14),
2379 combo.scalar.unwrap_or(100.0)
2380 );
2381 }
2382 }
2383 }
2384
2385 Ok(())
2386 }
2387
2388 #[cfg(not(debug_assertions))]
2389 fn check_batch_no_poison(
2390 _test: &str,
2391 _kernel: Kernel,
2392 ) -> Result<(), Box<dyn std::error::Error>> {
2393 Ok(())
2394 }
2395
2396 macro_rules! gen_batch_tests {
2397 ($fn_name:ident) => {
2398 paste::paste! {
2399 #[test] fn [<$fn_name _scalar>]() {
2400 let _ = $fn_name(stringify!([<$fn_name _scalar>]), Kernel::ScalarBatch);
2401 }
2402 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
2403 #[test] fn [<$fn_name _avx2>]() {
2404 let _ = $fn_name(stringify!([<$fn_name _avx2>]), Kernel::Avx2Batch);
2405 }
2406 #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
2407 #[test] fn [<$fn_name _avx512>]() {
2408 let _ = $fn_name(stringify!([<$fn_name _avx512>]), Kernel::Avx512Batch);
2409 }
2410 #[test] fn [<$fn_name _auto_detect>]() {
2411 let _ = $fn_name(stringify!([<$fn_name _auto_detect>]), Kernel::Auto);
2412 }
2413 }
2414 };
2415 }
2416
2417 gen_batch_tests!(check_batch_default_row);
2418 gen_batch_tests!(check_batch_no_poison);
2419
2420 #[test]
2421 fn test_ui_into_matches_api() -> Result<(), Box<dyn std::error::Error>> {
2422 let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
2423 let candles = read_candles_from_csv(file_path)?;
2424 let input = UiInput::with_default_candles(&candles);
2425
2426 let baseline = ui(&input)?.values;
2427
2428 let mut out = vec![0.0; baseline.len()];
2429 #[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
2430 {
2431 ui_into(&input, &mut out)?;
2432 }
2433 #[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2434 {
2435 ui_into_slice(&mut out, &input, Kernel::Auto)?;
2436 }
2437
2438 assert_eq!(baseline.len(), out.len());
2439
2440 fn eq_or_both_nan(a: f64, b: f64) -> bool {
2441 (a.is_nan() && b.is_nan()) || (a == b)
2442 }
2443 for i in 0..baseline.len() {
2444 assert!(
2445 eq_or_both_nan(baseline[i], out[i]),
2446 "mismatch at index {i}: baseline={:?}, into={:?}",
2447 baseline[i],
2448 out[i]
2449 );
2450 }
2451
2452 Ok(())
2453 }
2454}