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