Skip to main content

vector_ta/indicators/
ui.rs

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, &params)
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}