Skip to main content

vector_ta/indicators/
efi.rs

1#[cfg(feature = "python")]
2use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
3#[cfg(feature = "python")]
4use pyo3::exceptions::PyValueError;
5#[cfg(feature = "python")]
6use pyo3::prelude::*;
7#[cfg(feature = "python")]
8use pyo3::types::PyDict;
9
10use crate::utilities::data_loader::{source_type, Candles};
11use crate::utilities::enums::Kernel;
12use crate::utilities::helpers::{
13    alloc_with_nan_prefix, detect_best_batch_kernel, detect_best_kernel, init_matrix_prefixes,
14    make_uninit_matrix,
15};
16#[cfg(feature = "python")]
17use crate::utilities::kernel_validation::validate_kernel;
18use aligned_vec::{AVec, CACHELINE_ALIGN};
19#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
20use core::arch::x86_64::*;
21#[cfg(not(target_arch = "wasm32"))]
22use rayon::prelude::*;
23#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
24use serde::{Deserialize, Serialize};
25use std::convert::AsRef;
26use std::error::Error;
27use thiserror::Error;
28#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
29use wasm_bindgen::prelude::*;
30
31#[cfg(all(feature = "python", feature = "cuda"))]
32use crate::cuda::{cuda_available, CudaEfi, DeviceArrayF32};
33#[cfg(all(feature = "python", feature = "cuda"))]
34use cust::context::Context;
35#[cfg(all(feature = "python", feature = "cuda"))]
36use std::sync::Arc;
37
38#[inline(always)]
39fn first_valid_diff_index(price: &[f64], volume: &[f64], first_valid_idx: usize) -> usize {
40    let mut i = first_valid_idx.saturating_add(1);
41    while i < price.len() {
42        if !price[i].is_nan() && !price[i - 1].is_nan() && !volume[i].is_nan() {
43            return i;
44        }
45        i += 1;
46    }
47    price.len()
48}
49
50impl<'a> AsRef<[f64]> for EfiInput<'a> {
51    #[inline(always)]
52    fn as_ref(&self) -> &[f64] {
53        match &self.data {
54            EfiData::Candles { candles, source } => source_type(candles, source),
55            EfiData::Slice { price, .. } => price,
56        }
57    }
58}
59
60#[derive(Debug, Clone)]
61pub enum EfiData<'a> {
62    Candles {
63        candles: &'a Candles,
64        source: &'a str,
65    },
66    Slice {
67        price: &'a [f64],
68        volume: &'a [f64],
69    },
70}
71
72#[derive(Debug, Clone)]
73pub struct EfiOutput {
74    pub values: Vec<f64>,
75}
76
77#[derive(Debug, Clone)]
78#[cfg_attr(
79    all(target_arch = "wasm32", feature = "wasm"),
80    derive(serde::Serialize, serde::Deserialize)
81)]
82pub struct EfiParams {
83    pub period: Option<usize>,
84}
85
86impl Default for EfiParams {
87    fn default() -> Self {
88        Self { period: Some(13) }
89    }
90}
91
92#[derive(Debug, Clone)]
93pub struct EfiInput<'a> {
94    pub data: EfiData<'a>,
95    pub params: EfiParams,
96}
97
98impl<'a> EfiInput<'a> {
99    #[inline]
100    pub fn from_candles(c: &'a Candles, s: &'a str, p: EfiParams) -> Self {
101        Self {
102            data: EfiData::Candles {
103                candles: c,
104                source: s,
105            },
106            params: p,
107        }
108    }
109    #[inline]
110    pub fn from_slices(price: &'a [f64], volume: &'a [f64], p: EfiParams) -> Self {
111        Self {
112            data: EfiData::Slice { price, volume },
113            params: p,
114        }
115    }
116    #[inline]
117    pub fn with_default_candles(c: &'a Candles) -> Self {
118        Self::from_candles(c, "close", EfiParams::default())
119    }
120    #[inline]
121    pub fn get_period(&self) -> usize {
122        self.params.period.unwrap_or(13)
123    }
124}
125
126#[derive(Copy, Clone, Debug)]
127pub struct EfiBuilder {
128    period: Option<usize>,
129    kernel: Kernel,
130}
131
132impl Default for EfiBuilder {
133    fn default() -> Self {
134        Self {
135            period: None,
136            kernel: Kernel::Auto,
137        }
138    }
139}
140
141impl EfiBuilder {
142    #[inline(always)]
143    pub fn new() -> Self {
144        Self::default()
145    }
146    #[inline(always)]
147    pub fn period(mut self, n: usize) -> Self {
148        self.period = Some(n);
149        self
150    }
151    #[inline(always)]
152    pub fn kernel(mut self, k: Kernel) -> Self {
153        self.kernel = k;
154        self
155    }
156
157    #[inline(always)]
158    pub fn apply(self, c: &Candles) -> Result<EfiOutput, EfiError> {
159        let p = EfiParams {
160            period: self.period,
161        };
162        let i = EfiInput::from_candles(c, "close", p);
163        efi_with_kernel(&i, self.kernel)
164    }
165
166    #[inline(always)]
167    pub fn apply_slices(self, price: &[f64], volume: &[f64]) -> Result<EfiOutput, EfiError> {
168        let p = EfiParams {
169            period: self.period,
170        };
171        let i = EfiInput::from_slices(price, volume, p);
172        efi_with_kernel(&i, self.kernel)
173    }
174
175    #[inline(always)]
176    pub fn into_stream(self) -> Result<EfiStream, EfiError> {
177        let p = EfiParams {
178            period: self.period,
179        };
180        EfiStream::try_new(p)
181    }
182}
183
184#[derive(Debug, Error)]
185pub enum EfiError {
186    #[error("efi: Empty data provided.")]
187    EmptyInputData,
188    #[error("efi: Invalid period: period = {period}, data length = {data_len}")]
189    InvalidPeriod { period: usize, data_len: usize },
190    #[error("efi: Not enough valid data: needed = {needed}, valid = {valid}")]
191    NotEnoughValidData { needed: usize, valid: usize },
192    #[error("efi: All values are NaN.")]
193    AllValuesNaN,
194    #[error("efi: Output length mismatch: expected = {expected}, got = {got}")]
195    OutputLengthMismatch { expected: usize, got: usize },
196    #[error("efi: Invalid range expansion: start={start} end={end} step={step}")]
197    InvalidRange {
198        start: usize,
199        end: usize,
200        step: usize,
201    },
202    #[error("efi: Invalid kernel for batch path: {0:?}")]
203    InvalidKernelForBatch(crate::utilities::enums::Kernel),
204}
205
206#[inline]
207pub fn efi(input: &EfiInput) -> Result<EfiOutput, EfiError> {
208    efi_with_kernel(input, Kernel::Auto)
209}
210
211pub fn efi_with_kernel(input: &EfiInput, kernel: Kernel) -> Result<EfiOutput, EfiError> {
212    let (price, volume): (&[f64], &[f64]) = match &input.data {
213        EfiData::Candles { candles, source } => (source_type(candles, source), &candles.volume),
214        EfiData::Slice { price, volume } => (price, volume),
215    };
216
217    if price.is_empty() || volume.is_empty() || price.len() != volume.len() {
218        return Err(EfiError::EmptyInputData);
219    }
220
221    let len = price.len();
222    let period = input.get_period();
223    if period == 0 || period > len {
224        return Err(EfiError::InvalidPeriod {
225            period,
226            data_len: len,
227        });
228    }
229
230    let first = price
231        .iter()
232        .zip(volume.iter())
233        .position(|(p, v)| !p.is_nan() && !v.is_nan())
234        .ok_or(EfiError::AllValuesNaN)?;
235
236    if len - first < 2 {
237        return Err(EfiError::NotEnoughValidData {
238            needed: 2,
239            valid: len - first,
240        });
241    }
242
243    let warm = first_valid_diff_index(price, volume, first);
244    let chosen = match kernel {
245        Kernel::Auto => Kernel::Scalar,
246        other => other,
247    };
248
249    let mut out = alloc_with_nan_prefix(len, warm);
250
251    unsafe {
252        match chosen {
253            Kernel::Scalar | Kernel::ScalarBatch => {
254                efi_scalar(price, volume, period, first, &mut out)
255            }
256            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
257            Kernel::Avx2 | Kernel::Avx2Batch => efi_avx2(price, volume, period, first, &mut out),
258            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
259            Kernel::Avx512 | Kernel::Avx512Batch => {
260                efi_avx512(price, volume, period, first, &mut out)
261            }
262            _ => unreachable!(),
263        }
264    }
265    Ok(EfiOutput { values: out })
266}
267
268#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
269pub fn efi_into(input: &EfiInput, out: &mut [f64]) -> Result<(), EfiError> {
270    efi_into_slice(out, input, Kernel::Auto)
271}
272
273pub fn efi_into_slice(dst: &mut [f64], input: &EfiInput, kern: Kernel) -> Result<(), EfiError> {
274    let (price, volume): (&[f64], &[f64]) = match &input.data {
275        EfiData::Candles { candles, source } => (source_type(candles, source), &candles.volume),
276        EfiData::Slice { price, volume } => (price, volume),
277    };
278
279    if price.is_empty() || volume.is_empty() || price.len() != volume.len() {
280        return Err(EfiError::EmptyInputData);
281    }
282    let len = price.len();
283    if dst.len() != len {
284        return Err(EfiError::OutputLengthMismatch {
285            expected: len,
286            got: dst.len(),
287        });
288    }
289
290    let period = input.get_period();
291    if period == 0 || period > len {
292        return Err(EfiError::InvalidPeriod {
293            period,
294            data_len: len,
295        });
296    }
297
298    let first = price
299        .iter()
300        .zip(volume.iter())
301        .position(|(p, v)| !p.is_nan() && !v.is_nan())
302        .ok_or(EfiError::AllValuesNaN)?;
303
304    if len - first < 2 {
305        return Err(EfiError::NotEnoughValidData {
306            needed: 2,
307            valid: len - first,
308        });
309    }
310
311    let warm = first_valid_diff_index(price, volume, first);
312    let chosen = match kern {
313        Kernel::Auto => Kernel::Scalar,
314        other => other,
315    };
316
317    unsafe {
318        match chosen {
319            Kernel::Scalar | Kernel::ScalarBatch => efi_scalar(price, volume, period, first, dst),
320            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
321            Kernel::Avx2 | Kernel::Avx2Batch => efi_avx2(price, volume, period, first, dst),
322            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
323            Kernel::Avx512 | Kernel::Avx512Batch => efi_avx512(price, volume, period, first, dst),
324            _ => unreachable!(),
325        }
326    }
327
328    for v in &mut dst[..warm] {
329        *v = f64::NAN;
330    }
331    Ok(())
332}
333
334#[inline(always)]
335pub fn efi_scalar(
336    price: &[f64],
337    volume: &[f64],
338    period: usize,
339    first_valid_idx: usize,
340    out: &mut [f64],
341) {
342    let len = price.len();
343    if len == 0 {
344        return;
345    }
346
347    let start = first_valid_diff_index(price, volume, first_valid_idx);
348    if start >= len {
349        return;
350    }
351
352    let alpha = 2.0 / (period as f64 + 1.0);
353    let one_minus_alpha = 1.0 - alpha;
354
355    unsafe {
356        let p_ptr = price.as_ptr();
357        let v_ptr = volume.as_ptr();
358        let o_ptr = out.as_mut_ptr();
359
360        let p_cur = *p_ptr.add(start);
361        let p_prev = *p_ptr.add(start - 1);
362        let v_cur = *v_ptr.add(start);
363        let mut prev = (p_cur - p_prev) * v_cur;
364        *o_ptr.add(start) = prev;
365        let mut prev_price = p_cur;
366
367        let mut i = start + 1;
368        while i < len {
369            let pc = *p_ptr.add(i);
370            let vc = *v_ptr.add(i);
371
372            let valid = (pc == pc) & (prev_price == prev_price) & (vc == vc);
373            if valid {
374                let diff = (pc - prev_price) * vc;
375
376                prev = alpha.mul_add(diff, one_minus_alpha * prev);
377            }
378            *o_ptr.add(i) = prev;
379            prev_price = pc;
380            i += 1;
381        }
382    }
383}
384
385#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
386#[inline]
387pub fn efi_avx2(
388    price: &[f64],
389    volume: &[f64],
390    period: usize,
391    first_valid_idx: usize,
392    out: &mut [f64],
393) {
394    efi_scalar(price, volume, period, first_valid_idx, out)
395}
396
397#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
398#[inline]
399pub fn efi_avx512(
400    price: &[f64],
401    volume: &[f64],
402    period: usize,
403    first_valid_idx: usize,
404    out: &mut [f64],
405) {
406    if period <= 32 {
407        unsafe { efi_avx512_short(price, volume, period, first_valid_idx, out) }
408    } else {
409        unsafe { efi_avx512_long(price, volume, period, first_valid_idx, out) }
410    }
411}
412
413#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
414#[inline]
415pub unsafe fn efi_avx512_short(
416    price: &[f64],
417    volume: &[f64],
418    period: usize,
419    first_valid_idx: usize,
420    out: &mut [f64],
421) {
422    efi_scalar(price, volume, period, first_valid_idx, out)
423}
424
425#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
426#[inline]
427pub unsafe fn efi_avx512_long(
428    price: &[f64],
429    volume: &[f64],
430    period: usize,
431    first_valid_idx: usize,
432    out: &mut [f64],
433) {
434    efi_scalar(price, volume, period, first_valid_idx, out)
435}
436
437#[derive(Debug, Clone)]
438pub struct EfiStream {
439    period: usize,
440    alpha: f64,
441    prev: f64,
442    filled: bool,
443    last_price: f64,
444    has_last: bool,
445}
446
447impl EfiStream {
448    pub fn try_new(params: EfiParams) -> Result<Self, EfiError> {
449        let period = params.period.unwrap_or(13);
450        if period == 0 {
451            return Err(EfiError::InvalidPeriod {
452                period,
453                data_len: 0,
454            });
455        }
456        Ok(Self {
457            period,
458            alpha: 2.0 / (period as f64 + 1.0),
459            prev: f64::NAN,
460            filled: false,
461            last_price: f64::NAN,
462            has_last: false,
463        })
464    }
465
466    #[inline(always)]
467    pub fn update(&mut self, price: f64, volume: f64) -> Option<f64> {
468        if !self.has_last {
469            self.last_price = price;
470            self.has_last = true;
471            return None;
472        }
473
474        let valid = (price == price) & (self.last_price == self.last_price) & (volume == volume);
475
476        if !valid {
477            let out = if self.filled { self.prev } else { f64::NAN };
478            self.last_price = price;
479            return Some(out);
480        }
481
482        let diff = (price - self.last_price) * volume;
483
484        let out = if !self.filled {
485            self.prev = diff;
486            self.filled = true;
487            diff
488        } else {
489            self.prev = self.alpha.mul_add(diff - self.prev, self.prev);
490            self.prev
491        };
492
493        self.last_price = price;
494        Some(out)
495    }
496}
497
498#[derive(Clone, Debug)]
499pub struct EfiBatchRange {
500    pub period: (usize, usize, usize),
501}
502
503impl Default for EfiBatchRange {
504    fn default() -> Self {
505        Self {
506            period: (13, 262, 1),
507        }
508    }
509}
510
511#[derive(Clone, Debug, Default)]
512pub struct EfiBatchBuilder {
513    range: EfiBatchRange,
514    kernel: Kernel,
515}
516
517impl EfiBatchBuilder {
518    pub fn new() -> Self {
519        Self::default()
520    }
521    pub fn kernel(mut self, k: Kernel) -> Self {
522        self.kernel = k;
523        self
524    }
525    #[inline]
526    pub fn period_range(mut self, start: usize, end: usize, step: usize) -> Self {
527        self.range.period = (start, end, step);
528        self
529    }
530    #[inline]
531    pub fn period_static(mut self, p: usize) -> Self {
532        self.range.period = (p, p, 0);
533        self
534    }
535
536    pub fn apply_slices(self, price: &[f64], volume: &[f64]) -> Result<EfiBatchOutput, EfiError> {
537        efi_batch_with_kernel(price, volume, &self.range, self.kernel)
538    }
539
540    pub fn with_default_slices(
541        price: &[f64],
542        volume: &[f64],
543        k: Kernel,
544    ) -> Result<EfiBatchOutput, EfiError> {
545        EfiBatchBuilder::new().kernel(k).apply_slices(price, volume)
546    }
547
548    pub fn apply_candles(self, c: &Candles, src: &str) -> Result<EfiBatchOutput, EfiError> {
549        let slice = source_type(c, src);
550        let volume = &c.volume;
551        self.apply_slices(slice, volume)
552    }
553
554    pub fn with_default_candles(c: &Candles) -> Result<EfiBatchOutput, EfiError> {
555        EfiBatchBuilder::new()
556            .kernel(Kernel::Auto)
557            .apply_candles(c, "close")
558    }
559}
560
561pub fn efi_batch_with_kernel(
562    price: &[f64],
563    volume: &[f64],
564    sweep: &EfiBatchRange,
565    k: Kernel,
566) -> Result<EfiBatchOutput, EfiError> {
567    let kernel = match k {
568        Kernel::Auto => Kernel::ScalarBatch,
569        other if other.is_batch() => other,
570        other => return Err(EfiError::InvalidKernelForBatch(other)),
571    };
572
573    let simd = match kernel {
574        Kernel::Avx512Batch => Kernel::Avx512,
575        Kernel::Avx2Batch => Kernel::Avx2,
576        Kernel::ScalarBatch => Kernel::Scalar,
577        _ => unreachable!(),
578    };
579    efi_batch_par_slice(price, volume, sweep, simd)
580}
581
582#[derive(Clone, Debug)]
583pub struct EfiBatchOutput {
584    pub values: Vec<f64>,
585    pub combos: Vec<EfiParams>,
586    pub rows: usize,
587    pub cols: usize,
588}
589impl EfiBatchOutput {
590    pub fn row_for_params(&self, p: &EfiParams) -> Option<usize> {
591        self.combos
592            .iter()
593            .position(|c| c.period.unwrap_or(13) == p.period.unwrap_or(13))
594    }
595    pub fn values_for(&self, p: &EfiParams) -> Option<&[f64]> {
596        self.row_for_params(p).map(|row| {
597            let start = row * self.cols;
598            &self.values[start..start + self.cols]
599        })
600    }
601}
602
603#[inline(always)]
604fn expand_grid(r: &EfiBatchRange) -> Vec<EfiParams> {
605    fn axis_usize((start, end, step): (usize, usize, usize)) -> Vec<usize> {
606        if step == 0 || start == end {
607            return vec![start];
608        }
609        let mut out = Vec::new();
610        if start < end {
611            let mut v = start;
612            while v <= end {
613                out.push(v);
614                match v.checked_add(step) {
615                    Some(n) => {
616                        if n == v {
617                            break;
618                        }
619                        v = n;
620                    }
621                    None => break,
622                }
623            }
624        } else {
625            let mut v = start;
626            loop {
627                out.push(v);
628                if v <= end {
629                    break;
630                }
631                let next = v.saturating_sub(step);
632                if next == v {
633                    break;
634                }
635                v = next;
636            }
637        }
638        out
639    }
640    let periods = axis_usize(r.period);
641    let mut out = Vec::with_capacity(periods.len());
642    for &p in &periods {
643        out.push(EfiParams { period: Some(p) });
644    }
645    out
646}
647
648#[inline(always)]
649pub fn efi_batch_slice(
650    price: &[f64],
651    volume: &[f64],
652    sweep: &EfiBatchRange,
653    kern: Kernel,
654) -> Result<EfiBatchOutput, EfiError> {
655    efi_batch_inner(price, volume, sweep, kern, false)
656}
657
658#[inline(always)]
659pub fn efi_batch_par_slice(
660    price: &[f64],
661    volume: &[f64],
662    sweep: &EfiBatchRange,
663    kern: Kernel,
664) -> Result<EfiBatchOutput, EfiError> {
665    efi_batch_inner(price, volume, sweep, kern, true)
666}
667
668#[inline(always)]
669fn efi_batch_inner(
670    price: &[f64],
671    volume: &[f64],
672    sweep: &EfiBatchRange,
673    kern: Kernel,
674    parallel: bool,
675) -> Result<EfiBatchOutput, EfiError> {
676    let combos = expand_grid(sweep);
677    if combos.is_empty() {
678        return Err(EfiError::InvalidRange {
679            start: sweep.period.0,
680            end: sweep.period.1,
681            step: sweep.period.2,
682        });
683    }
684
685    let first = price
686        .iter()
687        .zip(volume.iter())
688        .position(|(p, v)| !p.is_nan() && !v.is_nan())
689        .ok_or(EfiError::AllValuesNaN)?;
690
691    if price.len() - first < 2 {
692        return Err(EfiError::NotEnoughValidData {
693            needed: 2,
694            valid: price.len() - first,
695        });
696    }
697
698    let rows = combos.len();
699    let cols = price.len();
700
701    let _cap = rows.checked_mul(cols).ok_or(EfiError::InvalidRange {
702        start: sweep.period.0,
703        end: sweep.period.1,
704        step: sweep.period.2,
705    })?;
706
707    let warm = first_valid_diff_index(price, volume, first);
708    let mut buf_mu = make_uninit_matrix(rows, cols);
709    let warm_prefixes = vec![warm; rows];
710    init_matrix_prefixes(&mut buf_mu, cols, &warm_prefixes);
711
712    let mut guard = core::mem::ManuallyDrop::new(buf_mu);
713    let out: &mut [f64] =
714        unsafe { core::slice::from_raw_parts_mut(guard.as_mut_ptr() as *mut f64, guard.len()) };
715
716    efi_batch_inner_into(price, volume, sweep, kern, parallel, out)?;
717
718    let values = unsafe {
719        Vec::from_raw_parts(
720            guard.as_mut_ptr() as *mut f64,
721            guard.len(),
722            guard.capacity(),
723        )
724    };
725
726    Ok(EfiBatchOutput {
727        values,
728        combos,
729        rows,
730        cols,
731    })
732}
733
734#[inline(always)]
735unsafe fn efi_row_scalar(
736    price: &[f64],
737    volume: &[f64],
738    first: usize,
739    period: usize,
740    out: &mut [f64],
741) {
742    efi_scalar(price, volume, period, first, out);
743}
744
745#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
746#[inline(always)]
747unsafe fn efi_row_avx2(
748    price: &[f64],
749    volume: &[f64],
750    first: usize,
751    period: usize,
752    out: &mut [f64],
753) {
754    efi_scalar(price, volume, period, first, out);
755}
756
757#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
758#[inline(always)]
759pub unsafe fn efi_row_avx512(
760    price: &[f64],
761    volume: &[f64],
762    first: usize,
763    period: usize,
764    out: &mut [f64],
765) {
766    if period <= 32 {
767        efi_row_avx512_short(price, volume, first, period, out);
768    } else {
769        efi_row_avx512_long(price, volume, first, period, out);
770    }
771    _mm_sfence();
772}
773
774#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
775#[inline(always)]
776unsafe fn efi_row_avx512_short(
777    price: &[f64],
778    volume: &[f64],
779    first: usize,
780    period: usize,
781    out: &mut [f64],
782) {
783    efi_scalar(price, volume, period, first, out);
784}
785
786#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
787#[inline(always)]
788unsafe fn efi_row_avx512_long(
789    price: &[f64],
790    volume: &[f64],
791    first: usize,
792    period: usize,
793    out: &mut [f64],
794) {
795    efi_scalar(price, volume, period, first, out);
796}
797
798#[cfg(feature = "python")]
799#[pyfunction(name = "efi")]
800#[pyo3(signature = (price, volume, period, kernel=None))]
801pub fn efi_py<'py>(
802    py: Python<'py>,
803    price: PyReadonlyArray1<'py, f64>,
804    volume: PyReadonlyArray1<'py, f64>,
805    period: usize,
806    kernel: Option<&str>,
807) -> PyResult<Bound<'py, PyArray1<f64>>> {
808    use numpy::{IntoPyArray, PyArrayMethods};
809
810    let price_slice = price.as_slice()?;
811    let volume_slice = volume.as_slice()?;
812    let kern = validate_kernel(kernel, false)?;
813
814    let params = EfiParams {
815        period: Some(period),
816    };
817    let input = EfiInput::from_slices(price_slice, volume_slice, params);
818
819    let result_vec: Vec<f64> = py
820        .allow_threads(|| efi_with_kernel(&input, kern).map(|o| o.values))
821        .map_err(|e| PyValueError::new_err(e.to_string()))?;
822
823    Ok(result_vec.into_pyarray(py))
824}
825
826#[cfg(feature = "python")]
827#[pyclass(name = "EfiStream")]
828pub struct EfiStreamPy {
829    stream: EfiStream,
830}
831
832#[cfg(feature = "python")]
833#[pymethods]
834impl EfiStreamPy {
835    #[new]
836    fn new(period: usize) -> PyResult<Self> {
837        let params = EfiParams {
838            period: Some(period),
839        };
840        let stream =
841            EfiStream::try_new(params).map_err(|e| PyValueError::new_err(e.to_string()))?;
842        Ok(EfiStreamPy { stream })
843    }
844
845    fn update(&mut self, price: f64, volume: f64) -> Option<f64> {
846        self.stream.update(price, volume)
847    }
848}
849
850#[inline(always)]
851fn efi_batch_inner_into(
852    price: &[f64],
853    volume: &[f64],
854    sweep: &EfiBatchRange,
855    kern: Kernel,
856    parallel: bool,
857    out: &mut [f64],
858) -> Result<Vec<EfiParams>, EfiError> {
859    let combos = expand_grid(sweep);
860    if combos.is_empty() {
861        return Err(EfiError::InvalidRange {
862            start: sweep.period.0,
863            end: sweep.period.1,
864            step: sweep.period.2,
865        });
866    }
867
868    let first = price
869        .iter()
870        .zip(volume.iter())
871        .position(|(p, v)| !p.is_nan() && !v.is_nan())
872        .ok_or(EfiError::AllValuesNaN)?;
873
874    if price.len() - first < 2 {
875        return Err(EfiError::NotEnoughValidData {
876            needed: 2,
877            valid: price.len() - first,
878        });
879    }
880
881    let cols = price.len();
882    let warm = first_valid_diff_index(price, volume, first);
883
884    let rows = combos.len();
885    let cols = price.len();
886    let expected = rows.checked_mul(cols).ok_or(EfiError::InvalidRange {
887        start: sweep.period.0,
888        end: sweep.period.1,
889        step: sweep.period.2,
890    })?;
891    if out.len() != expected {
892        return Err(EfiError::OutputLengthMismatch {
893            expected,
894            got: out.len(),
895        });
896    }
897    for row in 0..rows {
898        let row_start = row * cols;
899        for i in 0..warm.min(cols) {
900            out[row_start + i] = f64::NAN;
901        }
902    }
903
904    let out_mu: &mut [std::mem::MaybeUninit<f64>] = unsafe {
905        std::slice::from_raw_parts_mut(
906            out.as_mut_ptr() as *mut std::mem::MaybeUninit<f64>,
907            out.len(),
908        )
909    };
910
911    let mut fi_raw_mu: Vec<std::mem::MaybeUninit<f64>> = Vec::with_capacity(cols);
912    unsafe {
913        fi_raw_mu.set_len(cols);
914    }
915    unsafe {
916        let p_ptr = price.as_ptr();
917        let v_ptr = volume.as_ptr();
918        let r_ptr = fi_raw_mu.as_mut_ptr();
919        let mut i = warm;
920        while i < cols {
921            let pc = *p_ptr.add(i);
922            let pp = *p_ptr.add(i - 1);
923            let vc = *v_ptr.add(i);
924            if (pc == pc) & (pp == pp) & (vc == vc) {
925                let val = (pc - pp) * vc;
926                std::ptr::write(r_ptr.add(i), std::mem::MaybeUninit::new(val));
927            }
928            i += 1;
929        }
930    }
931
932    let row_fn = |row: usize, dst_row_mu: &mut [std::mem::MaybeUninit<f64>]| unsafe {
933        let period = combos[row].period.unwrap();
934        let dst: &mut [f64] =
935            std::slice::from_raw_parts_mut(dst_row_mu.as_mut_ptr() as *mut f64, dst_row_mu.len());
936        efi_row_from_precomputed(price, volume, &fi_raw_mu, warm, period, dst)
937    };
938
939    if parallel {
940        #[cfg(not(target_arch = "wasm32"))]
941        {
942            out_mu
943                .par_chunks_mut(cols)
944                .enumerate()
945                .for_each(|(r, s)| row_fn(r, s));
946        }
947        #[cfg(target_arch = "wasm32")]
948        {
949            for (r, s) in out_mu.chunks_mut(cols).enumerate() {
950                row_fn(r, s);
951            }
952        }
953    } else {
954        for (r, s) in out_mu.chunks_mut(cols).enumerate() {
955            row_fn(r, s);
956        }
957    }
958
959    Ok(combos)
960}
961
962#[inline(always)]
963fn efi_row_from_precomputed(
964    price: &[f64],
965    volume: &[f64],
966    fi_raw: &[std::mem::MaybeUninit<f64>],
967    start: usize,
968    period: usize,
969    out: &mut [f64],
970) {
971    let len = fi_raw.len();
972    if start >= len {
973        return;
974    }
975    let alpha = 2.0 / (period as f64 + 1.0);
976    let one_minus_alpha = 1.0 - alpha;
977    unsafe {
978        let r_ptr = fi_raw.as_ptr();
979        let o_ptr = out.as_mut_ptr();
980
981        let mut prev = (*r_ptr.add(start)).assume_init();
982        *o_ptr.add(start) = prev;
983        let mut i = start + 1;
984        while i < len {
985            let pc = *price.get_unchecked(i);
986            let pp = *price.get_unchecked(i - 1);
987            let vc = *volume.get_unchecked(i);
988            let valid = (pc == pc) & (pp == pp) & (vc == vc);
989            if valid {
990                let x = (*r_ptr.add(i)).assume_init();
991                prev = alpha.mul_add(x, one_minus_alpha * prev);
992            }
993            *o_ptr.add(i) = prev;
994            i += 1;
995        }
996    }
997}
998
999#[cfg(feature = "python")]
1000#[pyfunction(name = "efi_batch")]
1001#[pyo3(signature = (price, volume, period_range, kernel=None))]
1002pub fn efi_batch_py<'py>(
1003    py: Python<'py>,
1004    price: PyReadonlyArray1<'py, f64>,
1005    volume: PyReadonlyArray1<'py, f64>,
1006    period_range: (usize, usize, usize),
1007    kernel: Option<&str>,
1008) -> PyResult<Bound<'py, PyDict>> {
1009    use numpy::{IntoPyArray, PyArray1, PyArrayMethods};
1010    use pyo3::types::PyDict;
1011
1012    let price_slice = price.as_slice()?;
1013    let volume_slice = volume.as_slice()?;
1014    let kern = validate_kernel(kernel, true)?;
1015
1016    let sweep = EfiBatchRange {
1017        period: period_range,
1018    };
1019
1020    let combos = expand_grid(&sweep);
1021    let rows = combos.len();
1022    let cols = price_slice.len();
1023    let total = rows.checked_mul(cols).ok_or_else(|| {
1024        PyValueError::new_err("efi: Invalid range expansion (rows*cols overflow)")
1025    })?;
1026
1027    let out_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1028    let slice_out = unsafe { out_arr.as_slice_mut()? };
1029
1030    let combos = py
1031        .allow_threads(|| {
1032            let kernel = match kern {
1033                Kernel::Auto => detect_best_batch_kernel(),
1034                k => k,
1035            };
1036            let simd = match kernel {
1037                Kernel::Avx512Batch => Kernel::Avx512,
1038                Kernel::Avx2Batch => Kernel::Avx2,
1039                Kernel::ScalarBatch => Kernel::Scalar,
1040                _ => unreachable!(),
1041            };
1042            efi_batch_inner_into(price_slice, volume_slice, &sweep, simd, true, slice_out)
1043        })
1044        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1045
1046    let dict = PyDict::new(py);
1047    dict.set_item("values", out_arr.reshape((rows, cols))?)?;
1048    dict.set_item(
1049        "periods",
1050        combos
1051            .iter()
1052            .map(|p| p.period.unwrap() as u64)
1053            .collect::<Vec<_>>()
1054            .into_pyarray(py),
1055    )?;
1056
1057    Ok(dict)
1058}
1059
1060#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1061#[wasm_bindgen]
1062pub fn efi_js(price: &[f64], volume: &[f64], period: usize) -> Result<Vec<f64>, JsValue> {
1063    let params = EfiParams {
1064        period: Some(period),
1065    };
1066    let input = EfiInput::from_slices(price, volume, params);
1067
1068    let mut output = vec![0.0; price.len()];
1069    efi_into_slice(&mut output, &input, Kernel::Auto)
1070        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1071
1072    Ok(output)
1073}
1074
1075#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1076#[wasm_bindgen]
1077pub fn efi_alloc(len: usize) -> *mut f64 {
1078    let mut vec = Vec::<f64>::with_capacity(len);
1079    let ptr = vec.as_mut_ptr();
1080    std::mem::forget(vec);
1081    ptr
1082}
1083
1084#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1085#[wasm_bindgen]
1086pub fn efi_free(ptr: *mut f64, len: usize) {
1087    if !ptr.is_null() {
1088        unsafe {
1089            let _ = Vec::from_raw_parts(ptr, len, len);
1090        }
1091    }
1092}
1093
1094#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1095#[wasm_bindgen]
1096pub fn efi_into(
1097    in_price_ptr: *const f64,
1098    in_volume_ptr: *const f64,
1099    out_ptr: *mut f64,
1100    len: usize,
1101    period: usize,
1102) -> Result<(), JsValue> {
1103    if in_price_ptr.is_null() || in_volume_ptr.is_null() || out_ptr.is_null() {
1104        return Err(JsValue::from_str("Null pointer provided"));
1105    }
1106
1107    unsafe {
1108        let price = std::slice::from_raw_parts(in_price_ptr, len);
1109        let volume = std::slice::from_raw_parts(in_volume_ptr, len);
1110        let params = EfiParams {
1111            period: Some(period),
1112        };
1113        let input = EfiInput::from_slices(price, volume, params);
1114
1115        if in_price_ptr == out_ptr || in_volume_ptr == out_ptr {
1116            let mut temp = vec![0.0; len];
1117            efi_into_slice(&mut temp, &input, Kernel::Auto)
1118                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1119            let out = std::slice::from_raw_parts_mut(out_ptr, len);
1120            out.copy_from_slice(&temp);
1121        } else {
1122            let out = std::slice::from_raw_parts_mut(out_ptr, len);
1123            efi_into_slice(out, &input, Kernel::Auto)
1124                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1125        }
1126        Ok(())
1127    }
1128}
1129
1130#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1131#[derive(Serialize, Deserialize)]
1132pub struct EfiBatchConfig {
1133    pub period_range: (usize, usize, usize),
1134}
1135
1136#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1137#[derive(Serialize, Deserialize)]
1138pub struct EfiBatchJsOutput {
1139    pub values: Vec<f64>,
1140    pub combos: Vec<EfiParams>,
1141    pub rows: usize,
1142    pub cols: usize,
1143}
1144
1145#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1146#[wasm_bindgen(js_name = efi_batch)]
1147pub fn efi_batch_js(price: &[f64], volume: &[f64], config: JsValue) -> Result<JsValue, JsValue> {
1148    let config: EfiBatchConfig = serde_wasm_bindgen::from_value(config)
1149        .map_err(|e| JsValue::from_str(&format!("Invalid config: {}", e)))?;
1150
1151    let sweep = EfiBatchRange {
1152        period: config.period_range,
1153    };
1154
1155    let output = efi_batch_with_kernel(price, volume, &sweep, Kernel::Auto)
1156        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1157
1158    let js_output = EfiBatchJsOutput {
1159        values: output.values,
1160        combos: output.combos,
1161        rows: output.rows,
1162        cols: output.cols,
1163    };
1164
1165    serde_wasm_bindgen::to_value(&js_output)
1166        .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
1167}
1168
1169#[cfg(all(feature = "python", feature = "cuda"))]
1170#[pyclass(module = "ta_indicators.cuda", unsendable)]
1171pub struct EfiDeviceArrayF32Py {
1172    pub(crate) inner: Option<DeviceArrayF32>,
1173    pub(crate) ctx: Arc<Context>,
1174    pub(crate) device_id: i32,
1175}
1176
1177#[cfg(all(feature = "python", feature = "cuda"))]
1178#[pymethods]
1179impl EfiDeviceArrayF32Py {
1180    #[getter]
1181    fn __cuda_array_interface__<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
1182        let inner = self
1183            .inner
1184            .as_ref()
1185            .ok_or_else(|| PyValueError::new_err("buffer already exported via __dlpack__"))?;
1186        let d = PyDict::new(py);
1187        d.set_item("shape", (inner.rows, inner.cols))?;
1188        d.set_item("typestr", "<f4")?;
1189        d.set_item(
1190            "strides",
1191            (
1192                inner.cols * std::mem::size_of::<f32>(),
1193                std::mem::size_of::<f32>(),
1194            ),
1195        )?;
1196        d.set_item("data", (inner.device_ptr() as usize, false))?;
1197
1198        d.set_item("version", 3)?;
1199        Ok(d)
1200    }
1201
1202    fn __dlpack_device__(&self) -> (i32, i32) {
1203        (2, self.device_id)
1204    }
1205
1206    #[pyo3(signature = (stream=None, max_version=None, dl_device=None, copy=None))]
1207    fn __dlpack__<'py>(
1208        &mut self,
1209        py: Python<'py>,
1210        stream: Option<PyObject>,
1211        max_version: Option<PyObject>,
1212        dl_device: Option<PyObject>,
1213        copy: Option<PyObject>,
1214    ) -> PyResult<PyObject> {
1215        let (kdl, alloc_dev) = self.__dlpack_device__();
1216        if let Some(dev_obj) = dl_device.as_ref() {
1217            if let Ok((dev_ty, dev_id)) = dev_obj.extract::<(i32, i32)>(py) {
1218                if dev_ty != kdl || dev_id != alloc_dev {
1219                    let wants_copy = copy
1220                        .as_ref()
1221                        .and_then(|c| c.extract::<bool>(py).ok())
1222                        .unwrap_or(false);
1223                    if wants_copy {
1224                        return Err(PyValueError::new_err(
1225                            "device copy not implemented for __dlpack__",
1226                        ));
1227                    } else {
1228                        return Err(PyValueError::new_err("dl_device mismatch for __dlpack__"));
1229                    }
1230                }
1231            }
1232        }
1233        let _ = stream;
1234
1235        let inner = self
1236            .inner
1237            .take()
1238            .ok_or_else(|| PyValueError::new_err("buffer already exported via __dlpack__"))?;
1239
1240        let rows = inner.rows;
1241        let cols = inner.cols;
1242        let buf = inner.buf;
1243
1244        let max_version_bound = max_version.map(|obj| obj.into_bound(py));
1245
1246        crate::utilities::dlpack_cuda::export_f32_cuda_dlpack_2d(
1247            py,
1248            buf,
1249            rows,
1250            cols,
1251            alloc_dev,
1252            max_version_bound,
1253        )
1254    }
1255}
1256
1257#[cfg(all(feature = "python", feature = "cuda"))]
1258#[pyfunction(name = "efi_cuda_batch_dev")]
1259#[pyo3(signature = (price_f32, volume_f32, period_range=(13,13,0), device_id=0))]
1260pub fn efi_cuda_batch_dev_py(
1261    py: Python<'_>,
1262    price_f32: numpy::PyReadonlyArray1<'_, f32>,
1263    volume_f32: numpy::PyReadonlyArray1<'_, f32>,
1264    period_range: (usize, usize, usize),
1265    device_id: usize,
1266) -> PyResult<EfiDeviceArrayF32Py> {
1267    use numpy::PyArrayMethods;
1268    if !cuda_available() {
1269        return Err(PyValueError::new_err("CUDA not available"));
1270    }
1271    let p = price_f32.as_slice()?;
1272    let v = volume_f32.as_slice()?;
1273    if p.len() != v.len() {
1274        return Err(PyValueError::new_err(
1275            "price and volume must have same length",
1276        ));
1277    }
1278    let sweep = EfiBatchRange {
1279        period: period_range,
1280    };
1281    let (inner, ctx, dev_id) = py.allow_threads(|| {
1282        let cuda = CudaEfi::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
1283        let ctx = cuda.context_arc();
1284        let dev_id = cuda.device_id() as i32;
1285        let arr = cuda
1286            .efi_batch_dev(p, v, &sweep)
1287            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1288        Ok::<_, PyErr>((arr, ctx, dev_id))
1289    })?;
1290    Ok(EfiDeviceArrayF32Py {
1291        inner: Some(inner),
1292        ctx,
1293        device_id: dev_id,
1294    })
1295}
1296
1297#[cfg(all(feature = "python", feature = "cuda"))]
1298#[pyfunction(name = "efi_cuda_many_series_one_param_dev")]
1299#[pyo3(signature = (prices_tm_f32, volumes_tm_f32, period=13, device_id=0))]
1300pub fn efi_cuda_many_series_one_param_dev_py(
1301    py: Python<'_>,
1302    prices_tm_f32: numpy::PyReadonlyArray2<'_, f32>,
1303    volumes_tm_f32: numpy::PyReadonlyArray2<'_, f32>,
1304    period: usize,
1305    device_id: usize,
1306) -> PyResult<EfiDeviceArrayF32Py> {
1307    use numpy::PyArrayMethods;
1308    use numpy::PyUntypedArrayMethods;
1309    if !cuda_available() {
1310        return Err(PyValueError::new_err("CUDA not available"));
1311    }
1312    let p_flat = prices_tm_f32.as_slice()?;
1313    let v_flat = volumes_tm_f32.as_slice()?;
1314    let shp_p = prices_tm_f32.shape();
1315    let shp_v = volumes_tm_f32.shape();
1316    if shp_p.len() != 2 || shp_v.len() != 2 || shp_p != shp_v {
1317        return Err(PyValueError::new_err(
1318            "prices_tm and volumes_tm must be same 2D shape",
1319        ));
1320    }
1321    let rows = shp_p[0];
1322    let cols = shp_p[1];
1323    let params = EfiParams {
1324        period: Some(period),
1325    };
1326    let (inner, ctx, dev_id) = py.allow_threads(|| {
1327        let cuda = CudaEfi::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
1328        let ctx = cuda.context_arc();
1329        let dev_id = cuda.device_id() as i32;
1330        let arr = cuda
1331            .efi_many_series_one_param_time_major_dev(p_flat, v_flat, cols, rows, &params)
1332            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1333        Ok::<_, PyErr>((arr, ctx, dev_id))
1334    })?;
1335    Ok(EfiDeviceArrayF32Py {
1336        inner: Some(inner),
1337        ctx,
1338        device_id: dev_id,
1339    })
1340}
1341
1342#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1343#[wasm_bindgen]
1344pub fn efi_batch_into(
1345    in_price_ptr: *const f64,
1346    in_volume_ptr: *const f64,
1347    out_ptr: *mut f64,
1348    len: usize,
1349    period_start: usize,
1350    period_end: usize,
1351    period_step: usize,
1352) -> Result<usize, JsValue> {
1353    if in_price_ptr.is_null() || in_volume_ptr.is_null() || out_ptr.is_null() {
1354        return Err(JsValue::from_str("null pointer passed to efi_batch_into"));
1355    }
1356
1357    unsafe {
1358        let price = std::slice::from_raw_parts(in_price_ptr, len);
1359        let volume = std::slice::from_raw_parts(in_volume_ptr, len);
1360
1361        let sweep = EfiBatchRange {
1362            period: (period_start, period_end, period_step),
1363        };
1364
1365        let combos = expand_grid(&sweep);
1366        let rows = combos.len();
1367        let cols = len;
1368        let total = rows
1369            .checked_mul(cols)
1370            .ok_or_else(|| JsValue::from_str("efi_batch_into: rows*cols overflow"))?;
1371
1372        let out = std::slice::from_raw_parts_mut(out_ptr, total);
1373
1374        let first = price
1375            .iter()
1376            .zip(volume.iter())
1377            .position(|(p, v)| !p.is_nan() && !v.is_nan())
1378            .ok_or_else(|| JsValue::from_str("All values are NaN"))?;
1379
1380        let warm = first_valid_diff_index(price, volume, first);
1381
1382        for row in 0..rows {
1383            let row_start = row * cols;
1384            for i in 0..warm.min(cols) {
1385                out[row_start + i] = f64::NAN;
1386            }
1387        }
1388
1389        efi_batch_inner_into(price, volume, &sweep, Kernel::Auto, false, out)
1390            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1391
1392        Ok(rows)
1393    }
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398    use super::*;
1399    use crate::skip_if_unsupported;
1400    use crate::utilities::data_loader::read_candles_from_csv;
1401    use crate::utilities::enums::Kernel;
1402
1403    fn check_efi_partial_params(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1404        skip_if_unsupported!(kernel, test_name);
1405        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1406        let candles = read_candles_from_csv(file_path)?;
1407        let default_params = EfiParams { period: None };
1408        let input = EfiInput::from_candles(&candles, "close", default_params);
1409        let output = efi_with_kernel(&input, kernel)?;
1410        assert_eq!(output.values.len(), candles.close.len());
1411        Ok(())
1412    }
1413
1414    fn check_efi_accuracy(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1415        skip_if_unsupported!(kernel, test_name);
1416        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1417        let candles = read_candles_from_csv(file_path)?;
1418        let input = EfiInput::from_candles(&candles, "close", EfiParams::default());
1419        let result = efi_with_kernel(&input, kernel)?;
1420        let expected_last_five = [
1421            -44604.382026531224,
1422            -39811.02321812391,
1423            -36599.9671820205,
1424            -29903.28014503471,
1425            -55406.382981,
1426        ];
1427        let start = result.values.len().saturating_sub(5);
1428        for (i, &val) in result.values[start..].iter().enumerate() {
1429            let diff = (val - expected_last_five[i]).abs();
1430            assert!(
1431                diff < 1.0,
1432                "[{}] EFI {:?} mismatch at idx {}: got {}, expected {}",
1433                test_name,
1434                kernel,
1435                i,
1436                val,
1437                expected_last_five[i]
1438            );
1439        }
1440        Ok(())
1441    }
1442
1443    fn check_efi_zero_period(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1444        skip_if_unsupported!(kernel, test_name);
1445        let price = [10.0, 20.0, 30.0];
1446        let volume = [100.0, 200.0, 300.0];
1447        let params = EfiParams { period: Some(0) };
1448        let input = EfiInput::from_slices(&price, &volume, params);
1449        let res = efi_with_kernel(&input, kernel);
1450        assert!(
1451            res.is_err(),
1452            "[{}] EFI should fail with zero period",
1453            test_name
1454        );
1455        Ok(())
1456    }
1457
1458    fn check_efi_period_exceeds_length(
1459        test_name: &str,
1460        kernel: Kernel,
1461    ) -> Result<(), Box<dyn Error>> {
1462        skip_if_unsupported!(kernel, test_name);
1463        let price = [10.0, 20.0, 30.0];
1464        let volume = [100.0, 200.0, 300.0];
1465        let params = EfiParams { period: Some(10) };
1466        let input = EfiInput::from_slices(&price, &volume, params);
1467        let res = efi_with_kernel(&input, kernel);
1468        assert!(
1469            res.is_err(),
1470            "[{}] EFI should fail with period exceeding length",
1471            test_name
1472        );
1473        Ok(())
1474    }
1475
1476    fn check_efi_nan_handling(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1477        skip_if_unsupported!(kernel, test_name);
1478        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1479        let candles = read_candles_from_csv(file_path)?;
1480        let input = EfiInput::from_candles(&candles, "close", EfiParams { period: Some(13) });
1481        let res = efi_with_kernel(&input, kernel)?;
1482        assert_eq!(res.values.len(), candles.close.len());
1483        Ok(())
1484    }
1485
1486    fn check_efi_streaming(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1487        skip_if_unsupported!(kernel, test_name);
1488        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1489        let candles = read_candles_from_csv(file_path)?;
1490        let period = 13;
1491        let input = EfiInput::from_candles(
1492            &candles,
1493            "close",
1494            EfiParams {
1495                period: Some(period),
1496            },
1497        );
1498        let batch_output = efi_with_kernel(&input, kernel)?.values;
1499        let mut stream = EfiStream::try_new(EfiParams {
1500            period: Some(period),
1501        })?;
1502        let mut stream_values = Vec::with_capacity(candles.close.len());
1503        for (&p, &v) in candles.close.iter().zip(&candles.volume) {
1504            match stream.update(p, v) {
1505                Some(val) => stream_values.push(val),
1506                None => stream_values.push(f64::NAN),
1507            }
1508        }
1509        assert_eq!(batch_output.len(), stream_values.len());
1510        for (i, (&b, &s)) in batch_output.iter().zip(stream_values.iter()).enumerate() {
1511            if b.is_nan() && s.is_nan() {
1512                continue;
1513            }
1514            let diff = (b - s).abs();
1515            assert!(
1516                diff < 1.0,
1517                "[{}] EFI streaming mismatch at idx {}: batch={}, stream={}",
1518                test_name,
1519                i,
1520                b,
1521                s
1522            );
1523        }
1524        Ok(())
1525    }
1526
1527    #[cfg(debug_assertions)]
1528    fn check_efi_no_poison(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1529        skip_if_unsupported!(kernel, test_name);
1530
1531        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1532        let candles = read_candles_from_csv(file_path)?;
1533
1534        let test_params = vec![
1535            EfiParams::default(),
1536            EfiParams { period: Some(2) },
1537            EfiParams { period: Some(5) },
1538            EfiParams { period: Some(7) },
1539            EfiParams { period: Some(10) },
1540            EfiParams { period: Some(20) },
1541            EfiParams { period: Some(30) },
1542            EfiParams { period: Some(50) },
1543            EfiParams { period: Some(100) },
1544            EfiParams { period: Some(200) },
1545            EfiParams { period: Some(500) },
1546        ];
1547
1548        for (param_idx, params) in test_params.iter().enumerate() {
1549            let input = EfiInput::from_candles(&candles, "close", params.clone());
1550            let output = efi_with_kernel(&input, kernel)?;
1551
1552            for (i, &val) in output.values.iter().enumerate() {
1553                if val.is_nan() {
1554                    continue;
1555                }
1556
1557                let bits = val.to_bits();
1558
1559                if bits == 0x11111111_11111111 {
1560                    panic!(
1561                        "[{}] Found alloc_with_nan_prefix poison value {} (0x{:016X}) at index {} \
1562						 with params: {:?} (param set {})",
1563                        test_name, val, bits, i, params, param_idx
1564                    );
1565                }
1566
1567                if bits == 0x22222222_22222222 {
1568                    panic!(
1569                        "[{}] Found init_matrix_prefixes poison value {} (0x{:016X}) at index {} \
1570						 with params: {:?} (param set {})",
1571                        test_name, val, bits, i, params, param_idx
1572                    );
1573                }
1574
1575                if bits == 0x33333333_33333333 {
1576                    panic!(
1577                        "[{}] Found make_uninit_matrix poison value {} (0x{:016X}) at index {} \
1578						 with params: {:?} (param set {})",
1579                        test_name, val, bits, i, params, param_idx
1580                    );
1581                }
1582            }
1583        }
1584
1585        Ok(())
1586    }
1587
1588    #[cfg(not(debug_assertions))]
1589    fn check_efi_no_poison(_test_name: &str, _kernel: Kernel) -> Result<(), Box<dyn Error>> {
1590        Ok(())
1591    }
1592
1593    #[cfg(feature = "proptest")]
1594    #[allow(clippy::float_cmp)]
1595    fn check_efi_property(
1596        test_name: &str,
1597        kernel: Kernel,
1598    ) -> Result<(), Box<dyn std::error::Error>> {
1599        use proptest::prelude::*;
1600        skip_if_unsupported!(kernel, test_name);
1601
1602        let strat = (2usize..=50).prop_flat_map(|period| {
1603            (
1604                (100f64..10000f64, 0.01f64..0.05f64, period + 10..400)
1605                    .prop_flat_map(move |(base_price, volatility, data_len)| {
1606                        (
1607                            Just(base_price),
1608                            Just(volatility),
1609                            Just(data_len),
1610                            prop::collection::vec((-1f64..1f64), data_len),
1611                            prop::collection::vec((0.1f64..10f64), data_len),
1612                        )
1613                    })
1614                    .prop_map(
1615                        move |(
1616                            base_price,
1617                            volatility,
1618                            data_len,
1619                            price_changes,
1620                            volume_multipliers,
1621                        )| {
1622                            let mut price = Vec::with_capacity(data_len);
1623                            let mut volume = Vec::with_capacity(data_len);
1624                            let mut current_price = base_price;
1625                            let base_volume = 1000000.0;
1626
1627                            for i in 0..data_len {
1628                                let change = price_changes[i] * volatility * current_price;
1629                                current_price = (current_price + change).max(10.0);
1630                                price.push(current_price);
1631
1632                                let daily_volume = base_volume * volume_multipliers[i];
1633                                volume.push(daily_volume);
1634                            }
1635
1636                            (price, volume)
1637                        },
1638                    ),
1639                Just(period),
1640            )
1641        });
1642
1643        proptest::test_runner::TestRunner::default().run(&strat, |((price, volume), period)| {
1644            let params = EfiParams {
1645                period: Some(period),
1646            };
1647            let input = EfiInput::from_slices(&price, &volume, params);
1648
1649            let EfiOutput { values: out } = efi_with_kernel(&input, kernel).unwrap();
1650            let EfiOutput { values: ref_out } = efi_with_kernel(&input, Kernel::Scalar).unwrap();
1651
1652            prop_assert_eq!(out.len(), price.len(), "Output length mismatch");
1653
1654            prop_assert!(out[0].is_nan(), "First value should be NaN");
1655
1656            let constant_start = price
1657                .windows(3)
1658                .position(|w| w.iter().all(|&p| (p - w[0]).abs() < 1e-9));
1659
1660            if let Some(start) = constant_start {
1661                let mut constant_end = start + 3;
1662                while constant_end < price.len()
1663                    && (price[constant_end] - price[start]).abs() < 1e-9
1664                {
1665                    constant_end += 1;
1666                }
1667
1668                if constant_end - start >= period && constant_end < price.len() {
1669                    let check_idx = constant_end - 1;
1670                    if out[check_idx].is_finite() {
1671                        prop_assert!(
1672                            out[check_idx].abs() < 1e-6,
1673                            "EFI should approach 0 for constant price at idx {}: {}",
1674                            check_idx,
1675                            out[check_idx]
1676                        );
1677                    }
1678                }
1679            }
1680
1681            for i in 0..out.len() {
1682                let y = out[i];
1683                let r = ref_out[i];
1684
1685                let y_bits = y.to_bits();
1686                let r_bits = r.to_bits();
1687
1688                if !y.is_finite() || !r.is_finite() {
1689                    prop_assert_eq!(
1690                        y_bits,
1691                        r_bits,
1692                        "NaN/infinite mismatch at idx {}: {} vs {}",
1693                        i,
1694                        y,
1695                        r
1696                    );
1697                    continue;
1698                }
1699
1700                let ulp_diff: u64 = y_bits.abs_diff(r_bits);
1701                prop_assert!(
1702                    (y - r).abs() <= 1e-9 || ulp_diff <= 4,
1703                    "Kernel mismatch at idx {}: {} vs {} (ULP={})",
1704                    i,
1705                    y,
1706                    r,
1707                    ulp_diff
1708                );
1709            }
1710
1711            let alpha = 2.0 / (period as f64 + 1.0);
1712            for i in 2..out.len() {
1713                if out[i].is_finite()
1714                    && out[i - 1].is_finite()
1715                    && price[i].is_finite()
1716                    && price[i - 1].is_finite()
1717                    && volume[i].is_finite()
1718                {
1719                    let raw_fi = (price[i] - price[i - 1]) * volume[i];
1720
1721                    let expected = alpha * raw_fi + (1.0 - alpha) * out[i - 1];
1722
1723                    if (out[i] - expected).abs() > 1e-9 {
1724                        if i > period + 1 {
1725                            prop_assert!(
1726                                (out[i] - expected).abs() < 1e-6,
1727                                "EMA smoothing violated at idx {}: got {}, expected {} (diff: {})",
1728                                i,
1729                                out[i],
1730                                expected,
1731                                (out[i] - expected).abs()
1732                            );
1733                        }
1734                    }
1735                }
1736            }
1737
1738            Ok(())
1739        })?;
1740
1741        Ok(())
1742    }
1743
1744    macro_rules! generate_all_efi_tests {
1745        ($($test_fn:ident),*) => {
1746            paste::paste! {
1747                $(
1748                    #[test]
1749                    fn [<$test_fn _scalar_f64>]() {
1750                        let _ = $test_fn(stringify!([<$test_fn _scalar_f64>]), Kernel::Scalar);
1751                    }
1752                )*
1753                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1754                $(
1755                    #[test]
1756                    fn [<$test_fn _avx2_f64>]() {
1757                        let _ = $test_fn(stringify!([<$test_fn _avx2_f64>]), Kernel::Avx2);
1758                    }
1759                    #[test]
1760                    fn [<$test_fn _avx512_f64>]() {
1761                        let _ = $test_fn(stringify!([<$test_fn _avx512_f64>]), Kernel::Avx512);
1762                    }
1763                )*
1764            }
1765        }
1766    }
1767
1768    generate_all_efi_tests!(
1769        check_efi_partial_params,
1770        check_efi_accuracy,
1771        check_efi_zero_period,
1772        check_efi_period_exceeds_length,
1773        check_efi_nan_handling,
1774        check_efi_streaming,
1775        check_efi_no_poison
1776    );
1777
1778    #[cfg(feature = "proptest")]
1779    generate_all_efi_tests!(check_efi_property);
1780
1781    fn check_batch_default_row(test: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1782        skip_if_unsupported!(kernel, test);
1783        let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1784        let c = read_candles_from_csv(file)?;
1785        let output = EfiBatchBuilder::new()
1786            .kernel(kernel)
1787            .apply_candles(&c, "close")?;
1788        let def = EfiParams::default();
1789        let row = output.values_for(&def).expect("default row missing");
1790        assert_eq!(row.len(), c.close.len());
1791        Ok(())
1792    }
1793
1794    #[cfg(debug_assertions)]
1795    fn check_batch_no_poison(test: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1796        skip_if_unsupported!(kernel, test);
1797
1798        let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1799        let c = read_candles_from_csv(file)?;
1800
1801        let test_configs = vec![
1802            (2, 10, 2),
1803            (5, 25, 5),
1804            (30, 100, 10),
1805            (2, 5, 1),
1806            (10, 10, 0),
1807            (13, 13, 0),
1808            (50, 50, 0),
1809            (7, 21, 7),
1810            (100, 200, 50),
1811        ];
1812
1813        for (cfg_idx, &(p_start, p_end, p_step)) in test_configs.iter().enumerate() {
1814            let output = EfiBatchBuilder::new()
1815                .kernel(kernel)
1816                .period_range(p_start, p_end, p_step)
1817                .apply_candles(&c, "close")?;
1818
1819            for (idx, &val) in output.values.iter().enumerate() {
1820                if val.is_nan() {
1821                    continue;
1822                }
1823
1824                let bits = val.to_bits();
1825                let row = idx / output.cols;
1826                let col = idx % output.cols;
1827                let combo = &output.combos[row];
1828
1829                if bits == 0x11111111_11111111 {
1830                    panic!(
1831                        "[{}] Config {}: Found alloc_with_nan_prefix poison value {} (0x{:016X}) \
1832						at row {} col {} (flat index {}) with params: period={}",
1833                        test,
1834                        cfg_idx,
1835                        val,
1836                        bits,
1837                        row,
1838                        col,
1839                        idx,
1840                        combo.period.unwrap_or(13)
1841                    );
1842                }
1843
1844                if bits == 0x22222222_22222222 {
1845                    panic!(
1846                        "[{}] Config {}: Found init_matrix_prefixes poison value {} (0x{:016X}) \
1847						at row {} col {} (flat index {}) with params: period={}",
1848                        test,
1849                        cfg_idx,
1850                        val,
1851                        bits,
1852                        row,
1853                        col,
1854                        idx,
1855                        combo.period.unwrap_or(13)
1856                    );
1857                }
1858
1859                if bits == 0x33333333_33333333 {
1860                    panic!(
1861                        "[{}] Config {}: Found make_uninit_matrix poison value {} (0x{:016X}) \
1862						at row {} col {} (flat index {}) with params: period={}",
1863                        test,
1864                        cfg_idx,
1865                        val,
1866                        bits,
1867                        row,
1868                        col,
1869                        idx,
1870                        combo.period.unwrap_or(13)
1871                    );
1872                }
1873            }
1874        }
1875
1876        Ok(())
1877    }
1878
1879    #[cfg(not(debug_assertions))]
1880    fn check_batch_no_poison(_test: &str, _kernel: Kernel) -> Result<(), Box<dyn Error>> {
1881        Ok(())
1882    }
1883
1884    macro_rules! gen_batch_tests {
1885        ($fn_name:ident) => {
1886            paste::paste! {
1887                #[test] fn [<$fn_name _scalar>]()      {
1888                    let _ = $fn_name(stringify!([<$fn_name _scalar>]), Kernel::ScalarBatch);
1889                }
1890                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1891                #[test] fn [<$fn_name _avx2>]()        {
1892                    let _ = $fn_name(stringify!([<$fn_name _avx2>]), Kernel::Avx2Batch);
1893                }
1894                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1895                #[test] fn [<$fn_name _avx512>]()      {
1896                    let _ = $fn_name(stringify!([<$fn_name _avx512>]), Kernel::Avx512Batch);
1897                }
1898                #[test] fn [<$fn_name _auto_detect>]() {
1899                    let _ = $fn_name(stringify!([<$fn_name _auto_detect>]), Kernel::Auto);
1900                }
1901            }
1902        };
1903    }
1904    gen_batch_tests!(check_batch_default_row);
1905    gen_batch_tests!(check_batch_no_poison);
1906
1907    #[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
1908    #[test]
1909    fn test_efi_into_matches_api() -> Result<(), Box<dyn Error>> {
1910        let len = 256usize;
1911        let mut price = vec![0.0; len];
1912        let mut volume = vec![0.0; len];
1913
1914        price[0] = f64::NAN;
1915        volume[0] = 1_000.0;
1916        for i in 1..len {
1917            price[i] = 100.0 + (i as f64) * 0.5;
1918            volume[i] = 10_000.0 + (i as f64) * 100.0;
1919        }
1920
1921        let input = EfiInput::from_slices(&price, &volume, EfiParams::default());
1922
1923        let baseline = efi(&input)?.values;
1924
1925        let mut out = vec![0.0; len];
1926        efi_into(&input, &mut out)?;
1927
1928        assert_eq!(baseline.len(), out.len());
1929
1930        fn eq_or_both_nan(a: f64, b: f64) -> bool {
1931            (a.is_nan() && b.is_nan()) || (a == b)
1932        }
1933
1934        for i in 0..len {
1935            assert!(
1936                eq_or_both_nan(baseline[i], out[i]),
1937                "Mismatch at index {}: baseline={}, into={}",
1938                i,
1939                baseline[i],
1940                out[i]
1941            );
1942        }
1943        Ok(())
1944    }
1945}