Skip to main content

vector_ta/indicators/
coppock.rs

1use crate::indicators::moving_averages::ma::{ma, MaData};
2use crate::utilities::data_loader::{source_type, Candles};
3use crate::utilities::enums::Kernel;
4use crate::utilities::helpers::{
5    alloc_with_nan_prefix, detect_best_batch_kernel, detect_best_kernel, init_matrix_prefixes,
6    make_uninit_matrix,
7};
8use aligned_vec::{AVec, CACHELINE_ALIGN};
9#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
10use core::arch::x86_64::*;
11#[cfg(not(target_arch = "wasm32"))]
12use rayon::prelude::*;
13use std::convert::AsRef;
14use std::error::Error;
15use std::mem::ManuallyDrop;
16use thiserror::Error;
17
18#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
19use serde::{Deserialize, Serialize};
20#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
21use wasm_bindgen::prelude::*;
22
23impl<'a> AsRef<[f64]> for CoppockInput<'a> {
24    #[inline(always)]
25    fn as_ref(&self) -> &[f64] {
26        match &self.data {
27            CoppockData::Slice(slice) => slice,
28            CoppockData::Candles { candles, source } => source_type(candles, source),
29        }
30    }
31}
32
33#[derive(Debug, Clone)]
34pub enum CoppockData<'a> {
35    Candles {
36        candles: &'a Candles,
37        source: &'a str,
38    },
39    Slice(&'a [f64]),
40}
41
42#[derive(Debug, Clone)]
43pub struct CoppockOutput {
44    pub values: Vec<f64>,
45}
46
47#[derive(Debug, Clone)]
48#[cfg_attr(
49    all(target_arch = "wasm32", feature = "wasm"),
50    derive(Serialize, Deserialize)
51)]
52pub struct CoppockParams {
53    pub short_roc_period: Option<usize>,
54    pub long_roc_period: Option<usize>,
55    pub ma_period: Option<usize>,
56    pub ma_type: Option<String>,
57}
58
59impl Default for CoppockParams {
60    fn default() -> Self {
61        Self {
62            short_roc_period: Some(11),
63            long_roc_period: Some(14),
64            ma_period: Some(10),
65            ma_type: Some("wma".to_string()),
66        }
67    }
68}
69
70#[derive(Debug, Clone)]
71pub struct CoppockInput<'a> {
72    pub data: CoppockData<'a>,
73    pub params: CoppockParams,
74}
75
76impl<'a> CoppockInput<'a> {
77    #[inline]
78    pub fn from_candles(c: &'a Candles, s: &'a str, p: CoppockParams) -> Self {
79        Self {
80            data: CoppockData::Candles {
81                candles: c,
82                source: s,
83            },
84            params: p,
85        }
86    }
87    #[inline]
88    pub fn from_slice(sl: &'a [f64], p: CoppockParams) -> Self {
89        Self {
90            data: CoppockData::Slice(sl),
91            params: p,
92        }
93    }
94    #[inline]
95    pub fn with_default_candles(c: &'a Candles) -> Self {
96        Self::from_candles(c, "close", CoppockParams::default())
97    }
98    #[inline]
99    pub fn get_short_roc_period(&self) -> usize {
100        self.params.short_roc_period.unwrap_or(11)
101    }
102    #[inline]
103    pub fn get_long_roc_period(&self) -> usize {
104        self.params.long_roc_period.unwrap_or(14)
105    }
106    #[inline]
107    pub fn get_ma_period(&self) -> usize {
108        self.params.ma_period.unwrap_or(10)
109    }
110    #[inline]
111    pub fn get_ma_type(&self) -> &str {
112        self.params.ma_type.as_deref().unwrap_or("wma")
113    }
114}
115
116#[derive(Clone, Debug)]
117pub struct CoppockBuilder {
118    short: Option<usize>,
119    long: Option<usize>,
120    ma: Option<usize>,
121    ma_type: Option<String>,
122    kernel: Kernel,
123}
124
125impl Default for CoppockBuilder {
126    fn default() -> Self {
127        Self {
128            short: None,
129            long: None,
130            ma: None,
131            ma_type: None,
132            kernel: Kernel::Auto,
133        }
134    }
135}
136
137impl CoppockBuilder {
138    #[inline(always)]
139    pub fn new() -> Self {
140        Self::default()
141    }
142    #[inline(always)]
143    pub fn short_roc_period(mut self, n: usize) -> Self {
144        self.short = Some(n);
145        self
146    }
147    #[inline(always)]
148    pub fn long_roc_period(mut self, n: usize) -> Self {
149        self.long = Some(n);
150        self
151    }
152    #[inline(always)]
153    pub fn ma_period(mut self, n: usize) -> Self {
154        self.ma = Some(n);
155        self
156    }
157    #[inline(always)]
158    pub fn ma_type<T: Into<String>>(mut self, t: T) -> Self {
159        self.ma_type = Some(t.into());
160        self
161    }
162    #[inline(always)]
163    pub fn kernel(mut self, k: Kernel) -> Self {
164        self.kernel = k;
165        self
166    }
167    #[inline(always)]
168    pub fn apply(self, c: &Candles) -> Result<CoppockOutput, CoppockError> {
169        let p = CoppockParams {
170            short_roc_period: self.short,
171            long_roc_period: self.long,
172            ma_period: self.ma,
173            ma_type: self.ma_type,
174        };
175        let i = CoppockInput::from_candles(c, "close", p);
176        coppock_with_kernel(&i, self.kernel)
177    }
178    #[inline(always)]
179    pub fn apply_slice(self, d: &[f64]) -> Result<CoppockOutput, CoppockError> {
180        let p = CoppockParams {
181            short_roc_period: self.short,
182            long_roc_period: self.long,
183            ma_period: self.ma,
184            ma_type: self.ma_type,
185        };
186        let i = CoppockInput::from_slice(d, p);
187        coppock_with_kernel(&i, self.kernel)
188    }
189    #[inline(always)]
190    pub fn into_stream(self) -> Result<CoppockStream, CoppockError> {
191        let p = CoppockParams {
192            short_roc_period: self.short,
193            long_roc_period: self.long,
194            ma_period: self.ma,
195            ma_type: self.ma_type,
196        };
197        CoppockStream::try_new(p)
198    }
199}
200
201#[derive(Debug, Error)]
202pub enum CoppockError {
203    #[error("coppock: Empty data provided.")]
204    EmptyData,
205    #[error("coppock: All values are NaN.")]
206    AllValuesNaN,
207    #[error("coppock: Not enough valid data: needed = {needed}, valid = {valid}")]
208    NotEnoughValidData { needed: usize, valid: usize },
209    #[error(
210        "coppock: Invalid period usage => short={short}, long={long}, ma={ma}, data_len={data_len}"
211    )]
212    InvalidPeriod {
213        short: usize,
214        long: usize,
215        ma: usize,
216        data_len: usize,
217    },
218    #[error("coppock: Output length mismatch: expected = {expected}, got = {got}")]
219    OutputLengthMismatch { expected: usize, got: usize },
220    #[error("coppock: Invalid range: start={start}, end={end}, step={step}")]
221    InvalidRange {
222        start: usize,
223        end: usize,
224        step: usize,
225    },
226    #[error("coppock: Invalid kernel for batch: {0:?}")]
227    InvalidKernelForBatch(Kernel),
228    #[error("coppock: Invalid input: {0}")]
229    InvalidInput(String),
230    #[error("coppock: Underlying MA error: {0}")]
231    MaError(#[from] Box<dyn Error + Send + Sync>),
232}
233
234#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
235impl From<CoppockError> for JsValue {
236    fn from(err: CoppockError) -> Self {
237        JsValue::from_str(&err.to_string())
238    }
239}
240
241#[inline]
242pub fn coppock(input: &CoppockInput) -> Result<CoppockOutput, CoppockError> {
243    coppock_with_kernel(input, Kernel::Auto)
244}
245
246pub fn coppock_with_kernel(
247    input: &CoppockInput,
248    kernel: Kernel,
249) -> Result<CoppockOutput, CoppockError> {
250    let data: &[f64] = input.as_ref();
251    if data.is_empty() {
252        return Err(CoppockError::EmptyData);
253    }
254
255    let short = input.get_short_roc_period();
256    let long = input.get_long_roc_period();
257    let ma_p = input.get_ma_period();
258    let data_len = data.len();
259
260    if short == 0
261        || long == 0
262        || ma_p == 0
263        || short > data_len
264        || long > data_len
265        || ma_p > data_len
266    {
267        return Err(CoppockError::InvalidPeriod {
268            short,
269            long,
270            ma: ma_p,
271            data_len,
272        });
273    }
274
275    let first = data
276        .iter()
277        .position(|&x| !x.is_nan())
278        .ok_or(CoppockError::AllValuesNaN)?;
279    let largest_roc = short.max(long);
280    if (data_len - first) < largest_roc {
281        return Err(CoppockError::NotEnoughValidData {
282            needed: largest_roc,
283            valid: data_len - first,
284        });
285    }
286
287    let warmup_period = first + largest_roc;
288
289    let mut sum_roc = alloc_with_nan_prefix(data_len, warmup_period);
290
291    unsafe {
292        match match kernel {
293            Kernel::Auto => Kernel::Scalar,
294            other => other,
295        } {
296            Kernel::Scalar | Kernel::ScalarBatch => {
297                coppock_scalar(data, short, long, first, &mut sum_roc)
298            }
299            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
300            Kernel::Avx2 | Kernel::Avx2Batch => {
301                coppock_avx2(data, short, long, first, &mut sum_roc)
302            }
303            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
304            Kernel::Avx512 | Kernel::Avx512Batch => {
305                coppock_avx512(data, short, long, first, &mut sum_roc)
306            }
307            _ => coppock_scalar(data, short, long, first, &mut sum_roc),
308        }
309    }
310
311    let ma_type = input.get_ma_type();
312    let smoothed = ma(&ma_type, MaData::Slice(&sum_roc), ma_p).map_err(|e| {
313        use std::fmt;
314        #[derive(Debug)]
315        struct MaErrorWrapper(String);
316        impl fmt::Display for MaErrorWrapper {
317            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318                write!(f, "{}", self.0)
319            }
320        }
321        impl Error for MaErrorWrapper {}
322        CoppockError::MaError(Box::new(MaErrorWrapper(e.to_string())))
323    })?;
324
325    Ok(CoppockOutput { values: smoothed })
326}
327
328#[inline]
329pub fn coppock_into_slice(
330    out: &mut [f64],
331    input: &CoppockInput,
332    kernel: Kernel,
333) -> Result<(), CoppockError> {
334    let data: &[f64] = input.as_ref();
335    if data.is_empty() {
336        return Err(CoppockError::EmptyData);
337    }
338    if out.len() != data.len() {
339        return Err(CoppockError::OutputLengthMismatch {
340            expected: data.len(),
341            got: out.len(),
342        });
343    }
344
345    let short = input.get_short_roc_period();
346    let long = input.get_long_roc_period();
347    let ma_p = input.get_ma_period();
348    let data_len = data.len();
349
350    if short == 0
351        || long == 0
352        || ma_p == 0
353        || short > data_len
354        || long > data_len
355        || ma_p > data_len
356    {
357        return Err(CoppockError::InvalidPeriod {
358            short,
359            long,
360            ma: ma_p,
361            data_len,
362        });
363    }
364
365    let first = data
366        .iter()
367        .position(|&x| !x.is_nan())
368        .ok_or(CoppockError::AllValuesNaN)?;
369    let largest_roc = short.max(long);
370    if (data_len - first) < largest_roc {
371        return Err(CoppockError::NotEnoughValidData {
372            needed: largest_roc,
373            valid: data_len - first,
374        });
375    }
376
377    let warmup_period = first + largest_roc;
378
379    let mut sum_roc = alloc_with_nan_prefix(data_len, warmup_period);
380
381    let resolved_kernel = match kernel {
382        Kernel::Auto => Kernel::Scalar,
383        other => other,
384    };
385
386    let ma_type = input.get_ma_type();
387
388    if resolved_kernel == Kernel::Scalar
389        && (ma_type == "wma" || ma_type == "sma" || ma_type == "ema")
390    {
391        unsafe {
392            return coppock_scalar_classic(data, short, long, ma_p, ma_type, first, out);
393        }
394    }
395
396    unsafe {
397        match resolved_kernel {
398            Kernel::Scalar | Kernel::ScalarBatch => {
399                coppock_scalar(data, short, long, first, &mut sum_roc)
400            }
401            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
402            Kernel::Avx2 | Kernel::Avx2Batch => {
403                coppock_avx2(data, short, long, first, &mut sum_roc)
404            }
405            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
406            Kernel::Avx512 | Kernel::Avx512Batch => {
407                coppock_avx512(data, short, long, first, &mut sum_roc)
408            }
409            _ => coppock_scalar(data, short, long, first, &mut sum_roc),
410        }
411    }
412
413    let smoothed = ma(ma_type, MaData::Slice(&sum_roc), ma_p).map_err(|e| {
414        use std::fmt;
415        #[derive(Debug)]
416        struct MaErrorWrapper(String);
417        impl fmt::Display for MaErrorWrapper {
418            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
419                write!(f, "{}", self.0)
420            }
421        }
422        impl Error for MaErrorWrapper {}
423        CoppockError::MaError(Box::new(MaErrorWrapper(e.to_string())))
424    })?;
425
426    out.copy_from_slice(&smoothed);
427    Ok(())
428}
429
430#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
431#[inline]
432pub fn coppock_into(input: &CoppockInput, out: &mut [f64]) -> Result<(), CoppockError> {
433    coppock_into_slice(out, input, Kernel::ScalarBatch)
434}
435
436pub unsafe fn coppock_scalar_classic(
437    data: &[f64],
438    short: usize,
439    long: usize,
440    ma_period: usize,
441    ma_type: &str,
442    first: usize,
443    out: &mut [f64],
444) -> Result<(), CoppockError> {
445    let len = data.len();
446    let largest_roc = short.max(long);
447    let warmup_period = first + largest_roc;
448
449    let mut sum_roc = alloc_with_nan_prefix(len, warmup_period);
450    let start_idx = first + largest_roc;
451
452    for i in start_idx..len {
453        let current = data[i];
454        let prev_short = data[i - short];
455        let short_val = ((current / prev_short) - 1.0) * 100.0;
456        let prev_long = data[i - long];
457        let long_val = ((current / prev_long) - 1.0) * 100.0;
458        sum_roc[i] = short_val + long_val;
459    }
460
461    match ma_type {
462        "wma" => {
463            let warmup_final = warmup_period + ma_period - 1;
464            for i in 0..warmup_final.min(len) {
465                out[i] = f64::NAN;
466            }
467
468            let weight_sum = (ma_period * (ma_period + 1)) as f64 / 2.0;
469
470            for i in warmup_final..len {
471                let mut weighted_sum = 0.0;
472                let mut has_nan = false;
473
474                for j in 0..ma_period {
475                    let idx = i - ma_period + 1 + j;
476                    if sum_roc[idx].is_nan() {
477                        has_nan = true;
478                        break;
479                    }
480                    weighted_sum += sum_roc[idx] * (j + 1) as f64;
481                }
482
483                out[i] = if has_nan {
484                    f64::NAN
485                } else {
486                    weighted_sum / weight_sum
487                };
488            }
489        }
490        "sma" => {
491            let warmup_final = warmup_period + ma_period - 1;
492            for i in 0..warmup_final.min(len) {
493                out[i] = f64::NAN;
494            }
495
496            let mut sum = 0.0;
497            for i in warmup_period..(warmup_period + ma_period.min(len - warmup_period)) {
498                if !sum_roc[i].is_nan() {
499                    sum += sum_roc[i];
500                }
501            }
502
503            if warmup_final < len {
504                out[warmup_final] = sum / ma_period as f64;
505
506                for i in (warmup_final + 1)..len {
507                    if !sum_roc[i].is_nan() && !sum_roc[i - ma_period].is_nan() {
508                        sum += sum_roc[i] - sum_roc[i - ma_period];
509                        out[i] = sum / ma_period as f64;
510                    } else {
511                        out[i] = f64::NAN;
512                    }
513                }
514            }
515        }
516        "ema" => {
517            let warmup_final = warmup_period + ma_period - 1;
518            for i in 0..warmup_final.min(len) {
519                out[i] = f64::NAN;
520            }
521
522            let alpha = 2.0 / (ma_period as f64 + 1.0);
523            let mut ema_value = f64::NAN;
524
525            for i in warmup_period..len {
526                if !sum_roc[i].is_nan() {
527                    ema_value = sum_roc[i];
528                    out[i] = ema_value;
529
530                    for j in (i + 1)..len {
531                        if !sum_roc[j].is_nan() {
532                            ema_value = alpha * sum_roc[j] + (1.0 - alpha) * ema_value;
533                            out[j] = ema_value;
534                        } else {
535                            out[j] = f64::NAN;
536                        }
537                    }
538                    break;
539                }
540            }
541        }
542        _ => {
543            let smoothed = ma(ma_type, MaData::Slice(&sum_roc), ma_period).map_err(|e| {
544                CoppockError::MaError(Box::new(std::io::Error::new(
545                    std::io::ErrorKind::Other,
546                    e.to_string(),
547                )))
548            })?;
549            out.copy_from_slice(&smoothed);
550        }
551    }
552
553    Ok(())
554}
555
556#[inline]
557pub fn coppock_scalar(data: &[f64], short: usize, long: usize, first: usize, out: &mut [f64]) {
558    let largest = short.max(long);
559    let start_idx = first + largest;
560    for i in start_idx..data.len() {
561        let current = data[i];
562        let prev_short = data[i - short];
563        let short_val = ((current / prev_short) - 1.0) * 100.0;
564        let prev_long = data[i - long];
565        let long_val = ((current / prev_long) - 1.0) * 100.0;
566        out[i] = short_val + long_val;
567    }
568}
569
570#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
571#[target_feature(enable = "avx2")]
572pub unsafe fn coppock_avx2(data: &[f64], short: usize, long: usize, first: usize, out: &mut [f64]) {
573    use core::arch::x86_64::*;
574
575    let largest = short.max(long);
576    let start = first + largest;
577    let n = data.len();
578    if start >= n {
579        return;
580    }
581
582    let mut p_cur = data.as_ptr().add(start);
583    let mut p_ps = data.as_ptr().add(start - short);
584    let mut p_pl = data.as_ptr().add(start - long);
585    let mut p_out = out.as_mut_ptr().add(start);
586
587    let remaining = n - start;
588    let step = 4usize;
589    let vec_len = remaining / step * step;
590
591    let v_one = _mm256_set1_pd(1.0);
592    let v_scale = _mm256_set1_pd(100.0);
593
594    let end_vec = p_cur.add(vec_len);
595    while p_cur < end_vec {
596        let vc = _mm256_loadu_pd(p_cur);
597        let vs = _mm256_loadu_pd(p_ps);
598        let vl = _mm256_loadu_pd(p_pl);
599
600        let r_s = _mm256_div_pd(vc, vs);
601        let r_l = _mm256_div_pd(vc, vl);
602
603        let t0 = _mm256_mul_pd(_mm256_sub_pd(r_s, v_one), v_scale);
604        let t1 = _mm256_mul_pd(_mm256_sub_pd(r_l, v_one), v_scale);
605        let res = _mm256_add_pd(t0, t1);
606
607        _mm256_storeu_pd(p_out, res);
608
609        p_cur = p_cur.add(step);
610        p_ps = p_ps.add(step);
611        p_pl = p_pl.add(step);
612        p_out = p_out.add(step);
613    }
614
615    let tail = remaining - vec_len;
616    for _ in 0..tail {
617        let c = *p_cur;
618        let s = *p_ps;
619        let l = *p_pl;
620        let rs = (c / s - 1.0) * 100.0;
621        let rl = (c / l - 1.0) * 100.0;
622        *p_out = rs + rl;
623
624        p_cur = p_cur.add(1);
625        p_ps = p_ps.add(1);
626        p_pl = p_pl.add(1);
627        p_out = p_out.add(1);
628    }
629}
630
631#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
632#[target_feature(enable = "avx512f")]
633pub unsafe fn coppock_avx512(
634    data: &[f64],
635    short: usize,
636    long: usize,
637    first: usize,
638    out: &mut [f64],
639) {
640    if short.max(long) <= 32 {
641        coppock_avx512_short(data, short, long, first, out)
642    } else {
643        coppock_avx512_long(data, short, long, first, out)
644    }
645}
646
647#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
648#[target_feature(enable = "avx512f")]
649pub unsafe fn coppock_avx512_short(
650    data: &[f64],
651    short: usize,
652    long: usize,
653    first: usize,
654    out: &mut [f64],
655) {
656    use core::arch::x86_64::*;
657
658    let largest = short.max(long);
659    let start = first + largest;
660    let n = data.len();
661    if start >= n {
662        return;
663    }
664
665    let mut p_cur = data.as_ptr().add(start);
666    let mut p_ps = data.as_ptr().add(start - short);
667    let mut p_pl = data.as_ptr().add(start - long);
668    let mut p_out = out.as_mut_ptr().add(start);
669
670    let remaining = n - start;
671    let step = 8usize;
672    let vec_len = remaining / step * step;
673
674    let v_one = _mm512_set1_pd(1.0);
675    let v_scale = _mm512_set1_pd(100.0);
676
677    let end_vec = p_cur.add(vec_len);
678    while p_cur < end_vec {
679        let vc = _mm512_loadu_pd(p_cur);
680        let vs = _mm512_loadu_pd(p_ps);
681        let vl = _mm512_loadu_pd(p_pl);
682
683        let r_s = _mm512_div_pd(vc, vs);
684        let r_l = _mm512_div_pd(vc, vl);
685
686        let t0 = _mm512_mul_pd(_mm512_sub_pd(r_s, v_one), v_scale);
687        let t1 = _mm512_mul_pd(_mm512_sub_pd(r_l, v_one), v_scale);
688        let res = _mm512_add_pd(t0, t1);
689
690        _mm512_storeu_pd(p_out, res);
691
692        p_cur = p_cur.add(step);
693        p_ps = p_ps.add(step);
694        p_pl = p_pl.add(step);
695        p_out = p_out.add(step);
696    }
697
698    let tail = remaining - vec_len;
699    for _ in 0..tail {
700        let c = *p_cur;
701        let s = *p_ps;
702        let l = *p_pl;
703        let rs = (c / s - 1.0) * 100.0;
704        let rl = (c / l - 1.0) * 100.0;
705        *p_out = rs + rl;
706
707        p_cur = p_cur.add(1);
708        p_ps = p_ps.add(1);
709        p_pl = p_pl.add(1);
710        p_out = p_out.add(1);
711    }
712}
713
714#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
715#[target_feature(enable = "avx512f")]
716pub unsafe fn coppock_avx512_long(
717    data: &[f64],
718    short: usize,
719    long: usize,
720    first: usize,
721    out: &mut [f64],
722) {
723    coppock_avx512_short(data, short, long, first, out)
724}
725
726#[derive(Debug, Clone, Copy, PartialEq, Eq)]
727enum MaMode {
728    Wma,
729    Sma,
730    Ema,
731    Unsupported,
732}
733
734#[derive(Debug, Clone)]
735pub struct CoppockStream {
736    short: usize,
737    long: usize,
738    ma_period: usize,
739    ma_type: String,
740    mode: MaMode,
741
742    price: Vec<f64>,
743    inv_price: Vec<f64>,
744    p_head: usize,
745    p_filled: bool,
746
747    roc: Vec<f64>,
748    r_head: usize,
749    r_filled: bool,
750
751    ma_sum: f64,
752    wma_num: f64,
753    wma_denom: f64,
754
755    ema_alpha: f64,
756    ema_val: f64,
757    ema_init: bool,
758}
759
760#[inline(always)]
761fn parse_mode(s: &str) -> MaMode {
762    match s {
763        "wma" => MaMode::Wma,
764        "sma" => MaMode::Sma,
765        "ema" => MaMode::Ema,
766        _ => MaMode::Unsupported,
767    }
768}
769
770#[inline(always)]
771fn bump(i: &mut usize, n: usize) {
772    *i += 1;
773    if *i == n {
774        *i = 0;
775    }
776}
777
778#[inline(always)]
779fn wrap_sub(idx: usize, offset: usize, n: usize) -> usize {
780    let j = idx + n - offset;
781    if j >= n {
782        j - n
783    } else {
784        j
785    }
786}
787
788#[inline(always)]
789fn safe_inv(x: f64) -> f64 {
790    if x.is_finite() && x != 0.0 {
791        1.0 / x
792    } else {
793        f64::NAN
794    }
795}
796
797impl CoppockStream {
798    pub fn try_new(params: CoppockParams) -> Result<Self, CoppockError> {
799        let short = params.short_roc_period.unwrap_or(11);
800        let long = params.long_roc_period.unwrap_or(14);
801        let ma_period = params.ma_period.unwrap_or(10);
802        let ma_type = params.ma_type.unwrap_or_else(|| "wma".to_string());
803        if short == 0 || long == 0 || ma_period == 0 {
804            return Err(CoppockError::InvalidPeriod {
805                short,
806                long,
807                ma: ma_period,
808                data_len: 0,
809            });
810        }
811
812        let mode = parse_mode(&ma_type);
813
814        let price_cap = long.max(short) + 1;
815        let ma_cap = ma_period;
816
817        Ok(Self {
818            short,
819            long,
820            ma_period,
821            ma_type,
822            mode,
823
824            price: vec![f64::NAN; price_cap],
825            inv_price: vec![f64::NAN; price_cap],
826            p_head: 0,
827            p_filled: false,
828
829            roc: vec![f64::NAN; ma_cap],
830            r_head: 0,
831            r_filled: false,
832
833            ma_sum: 0.0,
834            wma_num: 0.0,
835            wma_denom: (ma_period * (ma_period + 1)) as f64 * 0.5,
836
837            ema_alpha: 2.0 / (ma_period as f64 + 1.0),
838            ema_val: f64::NAN,
839            ema_init: false,
840        })
841    }
842
843    #[inline(always)]
844    pub fn update(&mut self, value: f64) -> Option<f64> {
845        let p_n = self.price.len();
846        let write_p = self.p_head;
847
848        self.price[write_p] = value;
849        self.inv_price[write_p] = safe_inv(value);
850
851        bump(&mut self.p_head, p_n);
852        if !self.p_filled && self.p_head == 0 {
853            self.p_filled = true;
854        }
855        if !self.p_filled {
856            return None;
857        }
858
859        let cur_idx = write_p;
860        let prev_s_idx = wrap_sub(cur_idx, self.short, p_n);
861        let prev_l_idx = wrap_sub(cur_idx, self.long, p_n);
862
863        let cur = self.price[cur_idx];
864        let invs = self.inv_price[prev_s_idx];
865        let invl = self.inv_price[prev_l_idx];
866
867        if !(cur.is_finite() && invs.is_finite() && invl.is_finite()) {
868            return None;
869        }
870
871        let mut sum_roc = (cur * (invs + invl) - 2.0) * 100.0;
872
873        let n = self.ma_period;
874        let write_r = self.r_head;
875        let old = self.roc[write_r];
876
877        if !self.r_filled {
878            self.ma_sum += sum_roc;
879            self.wma_num += (write_r as f64 + 1.0) * sum_roc;
880
881            self.roc[write_r] = sum_roc;
882            bump(&mut self.r_head, n);
883
884            if !self.r_filled && self.r_head == 0 {
885                self.r_filled = true;
886            }
887
888            if !self.r_filled {
889                return None;
890            }
891
892            return Some(match self.mode {
893                MaMode::Wma => self.wma_num / self.wma_denom,
894                MaMode::Sma => self.ma_sum / n as f64,
895                MaMode::Ema => {
896                    self.ema_val = sum_roc;
897                    self.ema_init = true;
898                    self.ema_val
899                }
900                MaMode::Unsupported => return None,
901            });
902        }
903
904        let prev_sum = self.ma_sum;
905        self.ma_sum = prev_sum - old + sum_roc;
906
907        self.wma_num = self.wma_num + (n as f64) * sum_roc - prev_sum;
908
909        self.roc[write_r] = sum_roc;
910        bump(&mut self.r_head, n);
911
912        Some(match self.mode {
913            MaMode::Wma => self.wma_num / self.wma_denom,
914            MaMode::Sma => self.ma_sum / n as f64,
915            MaMode::Ema => {
916                if !self.ema_init {
917                    self.ema_val = sum_roc;
918                    self.ema_init = true;
919                } else {
920                    self.ema_val = self.ema_alpha * sum_roc + (1.0 - self.ema_alpha) * self.ema_val;
921                }
922                self.ema_val
923            }
924            MaMode::Unsupported => return None,
925        })
926    }
927}
928#[derive(Clone, Debug)]
929pub struct CoppockBatchRange {
930    pub short: (usize, usize, usize),
931    pub long: (usize, usize, usize),
932    pub ma: (usize, usize, usize),
933}
934
935impl Default for CoppockBatchRange {
936    fn default() -> Self {
937        Self {
938            short: (11, 11, 0),
939            long: (14, 14, 0),
940            ma: (10, 259, 1),
941        }
942    }
943}
944
945#[derive(Clone, Debug, Default)]
946pub struct CoppockBatchBuilder {
947    range: CoppockBatchRange,
948    kernel: Kernel,
949}
950
951impl CoppockBatchBuilder {
952    pub fn new() -> Self {
953        Self::default()
954    }
955    pub fn kernel(mut self, k: Kernel) -> Self {
956        self.kernel = k;
957        self
958    }
959    #[inline]
960    pub fn short_range(mut self, start: usize, end: usize, step: usize) -> Self {
961        self.range.short = (start, end, step);
962        self
963    }
964    #[inline]
965    pub fn short_static(mut self, n: usize) -> Self {
966        self.range.short = (n, n, 0);
967        self
968    }
969    #[inline]
970    pub fn long_range(mut self, start: usize, end: usize, step: usize) -> Self {
971        self.range.long = (start, end, step);
972        self
973    }
974    #[inline]
975    pub fn long_static(mut self, n: usize) -> Self {
976        self.range.long = (n, n, 0);
977        self
978    }
979    #[inline]
980    pub fn ma_range(mut self, start: usize, end: usize, step: usize) -> Self {
981        self.range.ma = (start, end, step);
982        self
983    }
984    #[inline]
985    pub fn ma_static(mut self, n: usize) -> Self {
986        self.range.ma = (n, n, 0);
987        self
988    }
989    pub fn apply_slice(self, data: &[f64]) -> Result<CoppockBatchOutput, CoppockError> {
990        coppock_batch_with_kernel(data, &self.range, self.kernel)
991    }
992    pub fn with_default_slice(data: &[f64], k: Kernel) -> Result<CoppockBatchOutput, CoppockError> {
993        CoppockBatchBuilder::new().kernel(k).apply_slice(data)
994    }
995    pub fn apply_candles(self, c: &Candles, src: &str) -> Result<CoppockBatchOutput, CoppockError> {
996        let slice = source_type(c, src);
997        self.apply_slice(slice)
998    }
999    pub fn with_default_candles(c: &Candles) -> Result<CoppockBatchOutput, CoppockError> {
1000        CoppockBatchBuilder::new()
1001            .kernel(Kernel::Auto)
1002            .apply_candles(c, "close")
1003    }
1004}
1005
1006pub fn coppock_batch_with_kernel(
1007    data: &[f64],
1008    sweep: &CoppockBatchRange,
1009    k: Kernel,
1010) -> Result<CoppockBatchOutput, CoppockError> {
1011    let kernel = match k {
1012        Kernel::Auto => Kernel::ScalarBatch,
1013        other if other.is_batch() => other,
1014        _ => return Err(CoppockError::InvalidKernelForBatch(k)),
1015    };
1016    let simd = match kernel {
1017        Kernel::Avx512Batch => Kernel::Avx512,
1018        Kernel::Avx2Batch => Kernel::Avx2,
1019        Kernel::ScalarBatch => Kernel::Scalar,
1020        _ => unreachable!(),
1021    };
1022    coppock_batch_par_slice(data, sweep, simd)
1023}
1024
1025#[derive(Clone, Debug)]
1026pub struct CoppockBatchOutput {
1027    pub values: Vec<f64>,
1028    pub combos: Vec<CoppockParams>,
1029    pub rows: usize,
1030    pub cols: usize,
1031}
1032
1033impl CoppockBatchOutput {
1034    pub fn row_for_params(&self, p: &CoppockParams) -> Option<usize> {
1035        self.combos.iter().position(|c| {
1036            c.short_roc_period.unwrap_or(11) == p.short_roc_period.unwrap_or(11)
1037                && c.long_roc_period.unwrap_or(14) == p.long_roc_period.unwrap_or(14)
1038                && c.ma_period.unwrap_or(10) == p.ma_period.unwrap_or(10)
1039        })
1040    }
1041    pub fn values_for(&self, p: &CoppockParams) -> Option<&[f64]> {
1042        self.row_for_params(p).map(|row| {
1043            let start = row * self.cols;
1044            &self.values[start..start + self.cols]
1045        })
1046    }
1047}
1048
1049#[inline(always)]
1050fn expand_grid(r: &CoppockBatchRange) -> Result<Vec<CoppockParams>, CoppockError> {
1051    fn axis_usize((start, end, step): (usize, usize, usize)) -> Result<Vec<usize>, CoppockError> {
1052        if step == 0 || start == end {
1053            return Ok(vec![start]);
1054        }
1055        let mut v = Vec::new();
1056        if start < end {
1057            let mut cur = start;
1058            loop {
1059                v.push(cur);
1060                if cur == end {
1061                    break;
1062                }
1063                cur =
1064                    cur.checked_add(step)
1065                        .ok_or(CoppockError::InvalidRange { start, end, step })?;
1066                if cur > end {
1067                    break;
1068                }
1069            }
1070        } else {
1071            let mut cur = start;
1072            loop {
1073                v.push(cur);
1074                if cur == end {
1075                    break;
1076                }
1077                cur =
1078                    cur.checked_sub(step)
1079                        .ok_or(CoppockError::InvalidRange { start, end, step })?;
1080                if cur < end {
1081                    break;
1082                }
1083            }
1084        }
1085        if v.is_empty() {
1086            return Err(CoppockError::InvalidRange { start, end, step });
1087        }
1088        Ok(v)
1089    }
1090    let shorts = axis_usize(r.short)?;
1091    let longs = axis_usize(r.long)?;
1092    let mas = axis_usize(r.ma)?;
1093    if shorts.is_empty() || longs.is_empty() || mas.is_empty() {
1094        return Err(CoppockError::InvalidRange {
1095            start: 0,
1096            end: 0,
1097            step: 0,
1098        });
1099    }
1100    let cap = shorts
1101        .len()
1102        .checked_mul(longs.len())
1103        .and_then(|x| x.checked_mul(mas.len()))
1104        .ok_or_else(|| CoppockError::InvalidInput("coppock: parameter grid too large".into()))?;
1105    let mut out = Vec::with_capacity(cap);
1106    for &s in &shorts {
1107        for &l in &longs {
1108            for &m in &mas {
1109                out.push(CoppockParams {
1110                    short_roc_period: Some(s),
1111                    long_roc_period: Some(l),
1112                    ma_period: Some(m),
1113                    ma_type: Some("wma".to_string()),
1114                });
1115            }
1116        }
1117    }
1118    Ok(out)
1119}
1120
1121#[inline(always)]
1122pub fn coppock_batch_slice(
1123    data: &[f64],
1124    sweep: &CoppockBatchRange,
1125    kern: Kernel,
1126) -> Result<CoppockBatchOutput, CoppockError> {
1127    coppock_batch_inner(data, sweep, kern, false)
1128}
1129
1130#[inline(always)]
1131pub fn coppock_batch_par_slice(
1132    data: &[f64],
1133    sweep: &CoppockBatchRange,
1134    kern: Kernel,
1135) -> Result<CoppockBatchOutput, CoppockError> {
1136    coppock_batch_inner(data, sweep, kern, true)
1137}
1138
1139#[inline(always)]
1140fn coppock_batch_inner(
1141    data: &[f64],
1142    sweep: &CoppockBatchRange,
1143    kern: Kernel,
1144    parallel: bool,
1145) -> Result<CoppockBatchOutput, CoppockError> {
1146    let combos = expand_grid(sweep)?;
1147    if combos.is_empty() {
1148        return Err(CoppockError::InvalidRange {
1149            start: 0,
1150            end: 0,
1151            step: 0,
1152        });
1153    }
1154    let first = data
1155        .iter()
1156        .position(|x| !x.is_nan())
1157        .ok_or(CoppockError::AllValuesNaN)?;
1158    let max_roc = combos
1159        .iter()
1160        .map(|c| c.short_roc_period.unwrap().max(c.long_roc_period.unwrap()))
1161        .max()
1162        .unwrap();
1163    let _ = combos.len().checked_mul(max_roc).ok_or_else(|| {
1164        CoppockError::InvalidInput("coppock: n_combos*max_period overflow".into())
1165    })?;
1166    if data.len() - first < max_roc {
1167        return Err(CoppockError::NotEnoughValidData {
1168            needed: max_roc,
1169            valid: data.len() - first,
1170        });
1171    }
1172
1173    let rows = combos.len();
1174    let cols = data.len();
1175    let _total = rows
1176        .checked_mul(cols)
1177        .ok_or_else(|| CoppockError::InvalidInput("rows*cols overflow".into()))?;
1178
1179    let mut buf_mu = make_uninit_matrix(rows, cols);
1180
1181    let warm: Vec<usize> = combos
1182        .iter()
1183        .map(|c| {
1184            let short = c.short_roc_period.unwrap();
1185            let long = c.long_roc_period.unwrap();
1186            let ma_p = c.ma_period.unwrap();
1187            let largest = short.max(long);
1188
1189            first + largest + (ma_p - 1)
1190        })
1191        .collect();
1192
1193    init_matrix_prefixes(&mut buf_mu, cols, &warm);
1194
1195    let mut buf_guard = ManuallyDrop::new(buf_mu);
1196    let values: &mut [f64] = unsafe {
1197        core::slice::from_raw_parts_mut(buf_guard.as_mut_ptr() as *mut f64, buf_guard.len())
1198    };
1199
1200    let inv: Vec<f64> = data.iter().map(|&x| 1.0f64 / x).collect();
1201
1202    let do_row = |row: usize, out_row: &mut [f64]| {
1203        let c = &combos[row];
1204        let short = c.short_roc_period.unwrap();
1205        let long = c.long_roc_period.unwrap();
1206        let ma_p = c.ma_period.unwrap();
1207        let ma_type = c.ma_type.as_deref().unwrap_or("wma");
1208        let largest = short.max(long);
1209
1210        let sum_roc_warmup = first + largest;
1211
1212        let mut sum_roc = alloc_with_nan_prefix(cols, sum_roc_warmup);
1213        coppock_row_scalar_with_inv(data, first, short, long, &inv, &mut sum_roc);
1214
1215        let smoothed = ma(&ma_type, MaData::Slice(&sum_roc), ma_p).expect("MA error inside batch");
1216
1217        out_row.copy_from_slice(&smoothed);
1218    };
1219
1220    if parallel {
1221        #[cfg(not(target_arch = "wasm32"))]
1222        {
1223            values
1224                .par_chunks_mut(cols)
1225                .enumerate()
1226                .for_each(|(row, slice)| do_row(row, slice));
1227        }
1228        #[cfg(target_arch = "wasm32")]
1229        {
1230            for (row, slice) in values.chunks_mut(cols).enumerate() {
1231                do_row(row, slice);
1232            }
1233        }
1234    } else {
1235        for (row, slice) in values.chunks_mut(cols).enumerate() {
1236            do_row(row, slice);
1237        }
1238    }
1239
1240    let values = unsafe {
1241        Vec::from_raw_parts(
1242            buf_guard.as_mut_ptr() as *mut f64,
1243            buf_guard.len(),
1244            buf_guard.capacity(),
1245        )
1246    };
1247
1248    Ok(CoppockBatchOutput {
1249        values,
1250        combos,
1251        rows,
1252        cols,
1253    })
1254}
1255
1256#[inline(always)]
1257pub fn coppock_batch_inner_into(
1258    data: &[f64],
1259    sweep: &CoppockBatchRange,
1260    kern: Kernel,
1261    parallel: bool,
1262    out: &mut [f64],
1263) -> Result<Vec<CoppockParams>, CoppockError> {
1264    let combos = expand_grid(sweep)?;
1265    if combos.is_empty() {
1266        return Err(CoppockError::InvalidRange {
1267            start: 0,
1268            end: 0,
1269            step: 0,
1270        });
1271    }
1272    let first = data
1273        .iter()
1274        .position(|x| !x.is_nan())
1275        .ok_or(CoppockError::AllValuesNaN)?;
1276    let max_roc = combos
1277        .iter()
1278        .map(|c| c.short_roc_period.unwrap().max(c.long_roc_period.unwrap()))
1279        .max()
1280        .unwrap();
1281    let _ = combos.len().checked_mul(max_roc).ok_or_else(|| {
1282        CoppockError::InvalidInput("coppock: n_combos*max_period overflow".into())
1283    })?;
1284    if data.len() - first < max_roc {
1285        return Err(CoppockError::NotEnoughValidData {
1286            needed: max_roc,
1287            valid: data.len() - first,
1288        });
1289    }
1290
1291    let rows = combos.len();
1292    let cols = data.len();
1293    let expected = rows
1294        .checked_mul(cols)
1295        .ok_or_else(|| CoppockError::InvalidInput("rows*cols overflow".into()))?;
1296    if out.len() != expected {
1297        return Err(CoppockError::OutputLengthMismatch {
1298            expected,
1299            got: out.len(),
1300        });
1301    }
1302
1303    let warm: Vec<usize> = combos
1304        .iter()
1305        .map(|c| {
1306            let short = c.short_roc_period.unwrap();
1307            let long = c.long_roc_period.unwrap();
1308            let ma_p = c.ma_period.unwrap();
1309            let largest = short.max(long);
1310            first + largest + (ma_p - 1)
1311        })
1312        .collect();
1313
1314    let out_mu: &mut [std::mem::MaybeUninit<f64>] = unsafe {
1315        std::slice::from_raw_parts_mut(
1316            out.as_mut_ptr() as *mut std::mem::MaybeUninit<f64>,
1317            out.len(),
1318        )
1319    };
1320    init_matrix_prefixes(out_mu, cols, &warm);
1321
1322    let inv: Vec<f64> = data.iter().map(|&x| 1.0f64 / x).collect();
1323
1324    let do_row = |row: usize, out_row: &mut [f64]| {
1325        let c = &combos[row];
1326        let short = c.short_roc_period.unwrap();
1327        let long = c.long_roc_period.unwrap();
1328        let ma_p = c.ma_period.unwrap();
1329        let ma_type = c.ma_type.as_deref().unwrap_or("wma");
1330        let largest = short.max(long);
1331        let sum_roc_warmup = first + largest;
1332
1333        let mut sum_roc = alloc_with_nan_prefix(cols, sum_roc_warmup);
1334
1335        coppock_row_scalar_with_inv(data, first, short, long, &inv, &mut sum_roc);
1336
1337        let smoothed = ma(&ma_type, MaData::Slice(&sum_roc), ma_p).expect("MA error inside batch");
1338
1339        out_row.copy_from_slice(&smoothed);
1340    };
1341
1342    if parallel {
1343        #[cfg(not(target_arch = "wasm32"))]
1344        {
1345            out.par_chunks_mut(cols)
1346                .enumerate()
1347                .for_each(|(row, slice)| do_row(row, slice));
1348        }
1349        #[cfg(target_arch = "wasm32")]
1350        {
1351            for (row, slice) in out.chunks_mut(cols).enumerate() {
1352                do_row(row, slice);
1353            }
1354        }
1355    } else {
1356        for (row, slice) in out.chunks_mut(cols).enumerate() {
1357            do_row(row, slice);
1358        }
1359    }
1360
1361    Ok(combos)
1362}
1363
1364#[inline(always)]
1365pub fn coppock_row_scalar(
1366    data: &[f64],
1367    first: usize,
1368    short: usize,
1369    long: usize,
1370    _stride: usize,
1371    _w_ptr: *const f64,
1372    _inv_n: f64,
1373    out: &mut [f64],
1374) {
1375    let largest = short.max(long);
1376    for i in (first + largest)..data.len() {
1377        let current = data[i];
1378        let prev_short = data[i - short];
1379        let short_val = ((current / prev_short) - 1.0) * 100.0;
1380        let prev_long = data[i - long];
1381        let long_val = ((current / prev_long) - 1.0) * 100.0;
1382        out[i] = short_val + long_val;
1383    }
1384}
1385
1386#[inline(always)]
1387fn coppock_row_scalar_with_inv(
1388    data: &[f64],
1389    first: usize,
1390    short: usize,
1391    long: usize,
1392    inv: &[f64],
1393    out: &mut [f64],
1394) {
1395    let largest = short.max(long);
1396    for i in (first + largest)..data.len() {
1397        let c = data[i];
1398        let is = inv[i - short];
1399        let il = inv[i - long];
1400        out[i] = (c * is + c * il - 2.0) * 100.0;
1401    }
1402}
1403
1404#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1405#[target_feature(enable = "avx2")]
1406pub unsafe fn coppock_row_avx2(
1407    data: &[f64],
1408    first: usize,
1409    short: usize,
1410    long: usize,
1411    stride: usize,
1412    w_ptr: *const f64,
1413    inv_n: f64,
1414    out: &mut [f64],
1415) {
1416    let _ = (stride, w_ptr, inv_n);
1417    coppock_avx2(data, short, long, first, out)
1418}
1419
1420#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1421#[target_feature(enable = "avx512f")]
1422pub unsafe fn coppock_row_avx512(
1423    data: &[f64],
1424    first: usize,
1425    short: usize,
1426    long: usize,
1427    stride: usize,
1428    w_ptr: *const f64,
1429    inv_n: f64,
1430    out: &mut [f64],
1431) {
1432    if short.max(long) <= 32 {
1433        coppock_row_avx512_short(data, first, short, long, stride, w_ptr, inv_n, out)
1434    } else {
1435        coppock_row_avx512_long(data, first, short, long, stride, w_ptr, inv_n, out)
1436    }
1437}
1438
1439#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1440#[target_feature(enable = "avx512f")]
1441pub unsafe fn coppock_row_avx512_short(
1442    data: &[f64],
1443    first: usize,
1444    short: usize,
1445    long: usize,
1446    stride: usize,
1447    w_ptr: *const f64,
1448    inv_n: f64,
1449    out: &mut [f64],
1450) {
1451    let _ = (stride, w_ptr, inv_n);
1452    coppock_avx512_short(data, short, long, first, out)
1453}
1454
1455#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1456#[target_feature(enable = "avx512f")]
1457pub unsafe fn coppock_row_avx512_long(
1458    data: &[f64],
1459    first: usize,
1460    short: usize,
1461    long: usize,
1462    stride: usize,
1463    w_ptr: *const f64,
1464    inv_n: f64,
1465    out: &mut [f64],
1466) {
1467    let _ = (stride, w_ptr, inv_n);
1468    coppock_avx512_long(data, short, long, first, out)
1469}
1470
1471#[inline(always)]
1472fn expand_grid_coppock(_r: &CoppockBatchRange) -> Vec<CoppockParams> {
1473    vec![CoppockParams::default()]
1474}
1475
1476#[cfg(test)]
1477mod tests {
1478    use super::*;
1479    use crate::skip_if_unsupported;
1480    use crate::utilities::data_loader::read_candles_from_csv;
1481
1482    fn check_coppock_partial_params(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1483        skip_if_unsupported!(kernel, test_name);
1484        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1485        let candles = read_candles_from_csv(file_path)?;
1486        let default_params = CoppockParams::default();
1487        let input = CoppockInput::from_candles(&candles, "close", default_params);
1488        let output = coppock_with_kernel(&input, kernel)?;
1489        assert_eq!(output.values.len(), candles.close.len());
1490        Ok(())
1491    }
1492
1493    #[test]
1494    fn test_coppock_into_matches_api() -> Result<(), Box<dyn Error>> {
1495        let n = 256usize;
1496        let data: Vec<f64> = (0..n).map(|i| 100.0 + (i as f64) * 0.25).collect();
1497
1498        let input = CoppockInput::from_slice(&data, CoppockParams::default());
1499
1500        let baseline = coppock(&input)?.values;
1501
1502        let mut out = vec![0.0; n];
1503        #[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
1504        {
1505            coppock_into(&input, &mut out)?;
1506        }
1507        #[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1508        {
1509            out.copy_from_slice(&baseline);
1510        }
1511
1512        assert_eq!(baseline.len(), out.len());
1513
1514        for i in 0..n {
1515            let a = baseline[i];
1516            let b = out[i];
1517            let eq = (a.is_nan() && b.is_nan()) || (a == b);
1518            assert!(eq, "mismatch at index {i}: baseline={a}, into={b}");
1519        }
1520
1521        Ok(())
1522    }
1523    fn check_coppock_accuracy(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1524        skip_if_unsupported!(kernel, test_name);
1525        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1526        let candles = read_candles_from_csv(file_path)?;
1527        let input = CoppockInput::with_default_candles(&candles);
1528        let result = coppock_with_kernel(&input, kernel)?;
1529        let expected_last_five = [
1530            -1.4542764618985533,
1531            -1.3795224034983653,
1532            -1.614331648987457,
1533            -1.9179048338714915,
1534            -2.1096548435774625,
1535        ];
1536        let start = result.values.len().saturating_sub(5);
1537        for (i, &val) in result.values[start..].iter().enumerate() {
1538            let diff = (val - expected_last_five[i]).abs();
1539            assert!(
1540                diff < 1e-7,
1541                "[{}] Coppock {:?} mismatch at idx {}: got {}, expected {}",
1542                test_name,
1543                kernel,
1544                i,
1545                val,
1546                expected_last_five[i]
1547            );
1548        }
1549        Ok(())
1550    }
1551    fn check_coppock_default_candles(
1552        test_name: &str,
1553        kernel: Kernel,
1554    ) -> Result<(), Box<dyn Error>> {
1555        skip_if_unsupported!(kernel, test_name);
1556        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1557        let candles = read_candles_from_csv(file_path)?;
1558        let input = CoppockInput::with_default_candles(&candles);
1559        match input.data {
1560            CoppockData::Candles { source, .. } => assert_eq!(source, "close"),
1561            _ => panic!("Expected CoppockData::Candles"),
1562        }
1563        let output = coppock_with_kernel(&input, kernel)?;
1564        assert_eq!(output.values.len(), candles.close.len());
1565        Ok(())
1566    }
1567    fn check_coppock_zero_period(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1568        skip_if_unsupported!(kernel, test_name);
1569        let input_data = [10.0, 20.0, 30.0];
1570        let params = CoppockParams {
1571            short_roc_period: Some(0),
1572            long_roc_period: Some(14),
1573            ma_period: Some(10),
1574            ma_type: Some("wma".to_string()),
1575        };
1576        let input = CoppockInput::from_slice(&input_data, params);
1577        let res = coppock_with_kernel(&input, kernel);
1578        assert!(
1579            res.is_err(),
1580            "[{}] Coppock should fail with zero short period",
1581            test_name
1582        );
1583        Ok(())
1584    }
1585    fn check_coppock_period_exceeds_length(
1586        test_name: &str,
1587        kernel: Kernel,
1588    ) -> Result<(), Box<dyn Error>> {
1589        skip_if_unsupported!(kernel, test_name);
1590        let data_small = [10.0, 20.0, 30.0];
1591        let params = CoppockParams {
1592            short_roc_period: Some(14),
1593            long_roc_period: Some(20),
1594            ma_period: Some(10),
1595            ma_type: Some("wma".to_string()),
1596        };
1597        let input = CoppockInput::from_slice(&data_small, params);
1598        let res = coppock_with_kernel(&input, kernel);
1599        assert!(
1600            res.is_err(),
1601            "[{}] Coppock should fail with short/long>data.len()",
1602            test_name
1603        );
1604        Ok(())
1605    }
1606    fn check_coppock_very_small_dataset(
1607        test_name: &str,
1608        kernel: Kernel,
1609    ) -> Result<(), Box<dyn Error>> {
1610        skip_if_unsupported!(kernel, test_name);
1611        let single_point = [42.0];
1612        let params = CoppockParams {
1613            short_roc_period: Some(11),
1614            long_roc_period: Some(14),
1615            ma_period: Some(10),
1616            ma_type: Some("wma".to_string()),
1617        };
1618        let input = CoppockInput::from_slice(&single_point, params);
1619        let res = coppock_with_kernel(&input, kernel);
1620        assert!(
1621            res.is_err(),
1622            "[{}] Coppock should fail with insufficient data",
1623            test_name
1624        );
1625        Ok(())
1626    }
1627    fn check_coppock_reinput(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1628        skip_if_unsupported!(kernel, test_name);
1629
1630        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1631        let candles = read_candles_from_csv(file_path)?;
1632        let default_params = CoppockParams::default();
1633        let first_input = CoppockInput::from_candles(&candles, "close", default_params.clone());
1634        let first_result = coppock_with_kernel(&first_input, kernel)?;
1635
1636        let second_params = CoppockParams {
1637            short_roc_period: Some(5),
1638            long_roc_period: Some(8),
1639            ma_period: Some(3),
1640            ma_type: Some("sma".to_string()),
1641        };
1642        let second_input = CoppockInput::from_slice(&first_result.values, second_params.clone());
1643        let second_result = coppock_with_kernel(&second_input, kernel)?;
1644
1645        assert_eq!(second_result.values.len(), first_result.values.len());
1646
1647        let short1 = default_params.short_roc_period.unwrap();
1648        let long1 = default_params.long_roc_period.unwrap();
1649        let ma1 = default_params.ma_period.unwrap();
1650        let largest1 = short1.max(long1);
1651        let first_valid1 = largest1 + (ma1 - 1);
1652
1653        let short2 = second_params.short_roc_period.unwrap();
1654        let long2 = second_params.long_roc_period.unwrap();
1655        let ma2 = second_params.ma_period.unwrap();
1656        let largest2 = short2.max(long2);
1657        let first_valid2 = first_valid1 + largest2 + (ma2 - 1);
1658
1659        for i in first_valid2..second_result.values.len() {
1660            assert!(
1661                !second_result.values[i].is_nan(),
1662                "[{}] Expected no NaN after index {}, found NaN at {}",
1663                test_name,
1664                first_valid2,
1665                i
1666            );
1667        }
1668
1669        Ok(())
1670    }
1671    fn check_coppock_nan_handling(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1672        skip_if_unsupported!(kernel, test_name);
1673        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1674        let candles = read_candles_from_csv(file_path)?;
1675        let input = CoppockInput::from_candles(
1676            &candles,
1677            "close",
1678            CoppockParams {
1679                short_roc_period: Some(11),
1680                long_roc_period: Some(14),
1681                ma_period: Some(10),
1682                ma_type: Some("wma".to_string()),
1683            },
1684        );
1685        let res = coppock_with_kernel(&input, kernel)?;
1686        assert_eq!(res.values.len(), candles.close.len());
1687        if res.values.len() > 30 {
1688            for (i, &val) in res.values[30..].iter().enumerate() {
1689                assert!(
1690                    !val.is_nan(),
1691                    "[{}] Found unexpected NaN at out-index {}",
1692                    test_name,
1693                    30 + i
1694                );
1695            }
1696        }
1697        Ok(())
1698    }
1699    fn check_coppock_streaming(test_name: &str, _kernel: Kernel) -> Result<(), Box<dyn Error>> {
1700        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1701        let candles = read_candles_from_csv(file_path)?;
1702        let short = 11;
1703        let long = 14;
1704        let ma_period = 10;
1705        let ma_type = "wma".to_string();
1706        let input = CoppockInput::from_candles(
1707            &candles,
1708            "close",
1709            CoppockParams {
1710                short_roc_period: Some(short),
1711                long_roc_period: Some(long),
1712                ma_period: Some(ma_period),
1713                ma_type: Some(ma_type.clone()),
1714            },
1715        );
1716        let batch_output = coppock_with_kernel(&input, Kernel::Scalar)?.values;
1717        let mut stream = CoppockStream::try_new(CoppockParams {
1718            short_roc_period: Some(short),
1719            long_roc_period: Some(long),
1720            ma_period: Some(ma_period),
1721            ma_type: Some(ma_type),
1722        })?;
1723        let mut stream_values = Vec::with_capacity(candles.close.len());
1724        for &price in &candles.close {
1725            match stream.update(price) {
1726                Some(v) => stream_values.push(v),
1727                None => stream_values.push(f64::NAN),
1728            }
1729        }
1730        assert_eq!(batch_output.len(), stream_values.len());
1731        for (i, (&b, &s)) in batch_output.iter().zip(stream_values.iter()).enumerate() {
1732            if b.is_nan() && s.is_nan() {
1733                continue;
1734            }
1735            let diff = (b - s).abs();
1736            assert!(
1737                diff < 1e-8,
1738                "[{}] Coppock streaming f64 mismatch at idx {}: batch={}, stream={}, diff={}",
1739                test_name,
1740                i,
1741                b,
1742                s,
1743                diff
1744            );
1745        }
1746        Ok(())
1747    }
1748
1749    #[cfg(debug_assertions)]
1750    fn check_coppock_no_poison(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1751        skip_if_unsupported!(kernel, test_name);
1752
1753        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1754        let candles = read_candles_from_csv(file_path)?;
1755
1756        let param_combos = vec![
1757            CoppockParams {
1758                short_roc_period: Some(11),
1759                long_roc_period: Some(14),
1760                ma_period: Some(10),
1761                ma_type: Some("wma".to_string()),
1762            },
1763            CoppockParams {
1764                short_roc_period: Some(5),
1765                long_roc_period: Some(8),
1766                ma_period: Some(3),
1767                ma_type: Some("sma".to_string()),
1768            },
1769            CoppockParams {
1770                short_roc_period: Some(20),
1771                long_roc_period: Some(25),
1772                ma_period: Some(15),
1773                ma_type: Some("ema".to_string()),
1774            },
1775        ];
1776
1777        for params in param_combos {
1778            let input = CoppockInput::from_candles(&candles, "close", params);
1779            let output = coppock_with_kernel(&input, kernel)?;
1780
1781            for (i, &val) in output.values.iter().enumerate() {
1782                if val.is_nan() {
1783                    continue;
1784                }
1785
1786                let bits = val.to_bits();
1787
1788                if bits == 0x11111111_11111111 {
1789                    panic!(
1790                        "[{}] Found alloc_with_nan_prefix poison value {} (0x{:016X}) at index {}",
1791                        test_name, val, bits, i
1792                    );
1793                }
1794
1795                if bits == 0x22222222_22222222 {
1796                    panic!(
1797                        "[{}] Found init_matrix_prefixes poison value {} (0x{:016X}) at index {}",
1798                        test_name, val, bits, i
1799                    );
1800                }
1801
1802                if bits == 0x33333333_33333333 {
1803                    panic!(
1804                        "[{}] Found make_uninit_matrix poison value {} (0x{:016X}) at index {}",
1805                        test_name, val, bits, i
1806                    );
1807                }
1808            }
1809        }
1810
1811        Ok(())
1812    }
1813
1814    #[cfg(not(debug_assertions))]
1815    fn check_coppock_no_poison(_test_name: &str, _kernel: Kernel) -> Result<(), Box<dyn Error>> {
1816        Ok(())
1817    }
1818
1819    macro_rules! generate_all_coppock_tests {
1820        ($($test_fn:ident),*) => {
1821            paste::paste! {
1822                $(
1823                    #[test]
1824                    fn [<$test_fn _scalar_f64>]() {
1825                        let _ = $test_fn(stringify!([<$test_fn _scalar_f64>]), Kernel::Scalar);
1826                    }
1827                )*
1828                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1829                $(
1830                    #[test]
1831                    fn [<$test_fn _avx2_f64>]() {
1832                        let _ = $test_fn(stringify!([<$test_fn _avx2_f64>]), Kernel::Avx2);
1833                    }
1834                    #[test]
1835                    fn [<$test_fn _avx512_f64>]() {
1836                        let _ = $test_fn(stringify!([<$test_fn _avx512_f64>]), Kernel::Avx512);
1837                    }
1838                )*
1839            }
1840        }
1841    }
1842    #[cfg(feature = "proptest")]
1843    #[allow(clippy::float_cmp)]
1844    fn check_coppock_property(
1845        test_name: &str,
1846        kernel: Kernel,
1847    ) -> Result<(), Box<dyn std::error::Error>> {
1848        use proptest::prelude::*;
1849        skip_if_unsupported!(kernel, test_name);
1850
1851        let random_data_strat =
1852            (2usize..=20, 5usize..=30, 2usize..=15).prop_flat_map(|(short, long, ma_period)| {
1853                let data_len = long.max(short) + ma_period + 50;
1854                (
1855                    prop::collection::vec(
1856                        (10.0f64..10000.0f64)
1857                            .prop_filter("positive finite", |x| x.is_finite() && *x > 0.0),
1858                        data_len..data_len + 100,
1859                    ),
1860                    Just(short),
1861                    Just(long),
1862                    Just(ma_period),
1863                    prop::sample::select(vec!["wma", "sma", "ema"]),
1864                )
1865            });
1866
1867        let constant_data_strat =
1868            (2usize..=15, 5usize..=20, 2usize..=10).prop_flat_map(|(short, long, ma_period)| {
1869                let data_len = long.max(short) + ma_period + 30;
1870                (
1871                    (100.0f64..1000.0f64).prop_map(move |val| vec![val; data_len]),
1872                    Just(short),
1873                    Just(long),
1874                    Just(ma_period),
1875                    Just("wma"),
1876                )
1877            });
1878
1879        let trending_data_strat =
1880            (2usize..=15, 5usize..=25, 2usize..=12).prop_flat_map(|(short, long, ma_period)| {
1881                let data_len = long.max(short) + ma_period + 40;
1882                (
1883                    prop::bool::ANY.prop_flat_map(move |increasing| {
1884                        if increasing {
1885                            Just(
1886                                (0..data_len)
1887                                    .map(|i| 100.0 + i as f64 * 2.0)
1888                                    .collect::<Vec<_>>(),
1889                            )
1890                        } else {
1891                            Just(
1892                                (0..data_len)
1893                                    .map(|i| 1000.0 - i as f64 * 2.0)
1894                                    .collect::<Vec<_>>(),
1895                            )
1896                        }
1897                    }),
1898                    Just(short),
1899                    Just(long),
1900                    Just(ma_period),
1901                    Just("sma"),
1902                )
1903            });
1904
1905        let edge_case_strat =
1906            (2usize..=3, 3usize..=5, 2usize..=3).prop_flat_map(|(short, long, ma_period)| {
1907                let data_len = 20;
1908                (
1909                    prop::collection::vec(
1910                        (50.0f64..150.0f64).prop_filter("positive", |x| *x > 0.0),
1911                        data_len..data_len + 10,
1912                    ),
1913                    Just(short),
1914                    Just(long),
1915                    Just(ma_period),
1916                    Just("wma"),
1917                )
1918            });
1919
1920        let equal_periods_strat =
1921            (5usize..=15, 2usize..=10).prop_flat_map(|(period, ma_period)| {
1922                let data_len = period + ma_period + 30;
1923                (
1924                    prop::collection::vec(
1925                        (50.0f64..500.0f64).prop_filter("positive", |x| *x > 0.0),
1926                        data_len..data_len + 20,
1927                    ),
1928                    Just(period),
1929                    Just(period),
1930                    Just(ma_period),
1931                    Just("wma"),
1932                )
1933            });
1934
1935        let nan_prefix_strat = (2usize..=10, 5usize..=15, 2usize..=8, 1usize..=5).prop_flat_map(
1936            |(short, long, ma_period, nan_count)| {
1937                let data_len = nan_count + long.max(short) + ma_period + 20;
1938                (
1939                    prop::collection::vec(
1940                        (100.0f64..1000.0f64),
1941                        data_len - nan_count..data_len - nan_count + 10,
1942                    )
1943                    .prop_map(move |mut vals| {
1944                        let mut result = vec![f64::NAN; nan_count];
1945                        result.append(&mut vals);
1946                        result
1947                    }),
1948                    Just(short),
1949                    Just(long),
1950                    Just(ma_period),
1951                    Just("sma"),
1952                )
1953            },
1954        );
1955
1956        let combined_strat = prop::strategy::Union::new(vec![
1957            random_data_strat.boxed(),
1958            constant_data_strat.boxed(),
1959            trending_data_strat.boxed(),
1960            edge_case_strat.boxed(),
1961            equal_periods_strat.boxed(),
1962            nan_prefix_strat.boxed(),
1963        ]);
1964
1965        proptest::test_runner::TestRunner::default()
1966            .run(
1967                &combined_strat,
1968                |(data, short, long, ma_period, ma_type)| {
1969                    let params = CoppockParams {
1970                        short_roc_period: Some(short),
1971                        long_roc_period: Some(long),
1972                        ma_period: Some(ma_period),
1973                        ma_type: Some(ma_type.to_string()),
1974                    };
1975                    let input = CoppockInput::from_slice(&data, params.clone());
1976
1977                    let result = coppock_with_kernel(&input, kernel);
1978                    prop_assert!(
1979                        result.is_ok(),
1980                        "Coppock computation failed: {:?}",
1981                        result.err()
1982                    );
1983                    let out = result.unwrap().values;
1984
1985                    let ref_result = coppock_with_kernel(&input, Kernel::Scalar);
1986                    prop_assert!(ref_result.is_ok(), "Reference computation failed");
1987                    let ref_out = ref_result.unwrap().values;
1988
1989                    let first = data.iter().position(|&x| !x.is_nan()).unwrap_or(0);
1990                    let largest_roc = short.max(long);
1991                    let warmup = first + largest_roc + (ma_period - 1);
1992
1993                    for i in warmup..data.len() {
1994                        let y = out[i];
1995                        let r = ref_out[i];
1996
1997                        if y.is_nan() != r.is_nan() {
1998                            prop_assert!(
1999                                false,
2000                                "NaN mismatch at index {}: kernel={:?}, ref={:?}",
2001                                i,
2002                                y,
2003                                r
2004                            );
2005                        }
2006
2007                        if y.is_finite() && r.is_finite() {
2008                            let y_bits = y.to_bits();
2009                            let r_bits = r.to_bits();
2010                            let ulp_diff = y_bits.abs_diff(r_bits);
2011
2012                            prop_assert!(
2013                                (y - r).abs() <= 1e-9 || ulp_diff <= 10,
2014                                "Value mismatch at index {}: kernel={}, ref={}, diff={}, ULP={}",
2015                                i,
2016                                y,
2017                                r,
2018                                (y - r).abs(),
2019                                ulp_diff
2020                            );
2021                        }
2022                    }
2023
2024                    let is_constant = data.windows(2).all(|w| (w[0] - w[1]).abs() < f64::EPSILON);
2025                    if is_constant && data.len() > warmup + 5 {
2026                        for i in (warmup + 5)..data.len() {
2027                            let val = out[i];
2028                            if val.is_finite() {
2029                                prop_assert!(
2030                                    val.abs() <= 1e-6,
2031                                    "Constant data should produce ~0, got {} at index {}",
2032                                    val,
2033                                    i
2034                                );
2035                            }
2036                        }
2037                    }
2038
2039                    let is_increasing = data.windows(2).all(|w| w[1] >= w[0]);
2040                    let is_decreasing = data.windows(2).all(|w| w[1] <= w[0]);
2041
2042                    if (is_increasing || is_decreasing) && data.len() > warmup + 10 {
2043                        let expected_positive = is_increasing;
2044
2045                        for i in (warmup + 10)..(warmup + 15).min(data.len()) {
2046                            let val = out[i];
2047                            if val.is_finite() && val.abs() > 1e-10 {
2048                                if expected_positive {
2049                                    prop_assert!(
2050									val >= -1e-6,
2051									"Expected positive Coppock for increasing data, got {} at index {}",
2052									val, i
2053								);
2054                                } else {
2055                                    prop_assert!(
2056									val <= 1e-6,
2057									"Expected negative Coppock for decreasing data, got {} at index {}",
2058									val, i
2059								);
2060                                }
2061                            }
2062                        }
2063                    }
2064
2065                    for i in warmup..data.len() {
2066                        let val = out[i];
2067                        prop_assert!(
2068                            val.is_finite() || val.is_nan(),
2069                            "Found non-finite value {} at index {}",
2070                            val,
2071                            i
2072                        );
2073                    }
2074
2075                    for i in warmup..data.len() {
2076                        let val = out[i];
2077                        if val.is_finite() {
2078                            prop_assert!(
2079							val.abs() <= 100_000.0,
2080							"Unreasonably large Coppock value {} at index {} (exceeds 100,000%)",
2081							val, i
2082						);
2083                        }
2084                    }
2085
2086                    for i in warmup..data.len() {
2087                        let val = out[i];
2088                        prop_assert!(
2089                            val.is_finite() || val.is_nan(),
2090                            "Found infinity at index {}: {}",
2091                            i,
2092                            val
2093                        );
2094
2095                        if i >= largest_roc {
2096                            let current = data[i];
2097                            let prev_short = data[i - short];
2098                            let prev_long = data[i - long];
2099
2100                            if current.is_finite()
2101                                && prev_short.is_finite()
2102                                && prev_long.is_finite()
2103                                && prev_short != 0.0
2104                                && prev_long != 0.0
2105                            {
2106                                if i >= warmup {
2107                                    prop_assert!(
2108									val.is_finite(),
2109									"Expected finite value but got {} at index {} with valid inputs",
2110									val, i
2111								);
2112                                }
2113                            }
2114                        }
2115                    }
2116
2117                    if short == long && data.len() > warmup + 5 {
2118                        for i in (warmup + 1)..data.len().min(warmup + 6) {
2119                            let val = out[i];
2120                            if val.is_finite() {
2121                                prop_assert!(
2122                                    val.abs() <= 100_000.0,
2123                                    "Equal periods produced unreasonable value {} at index {}",
2124                                    val,
2125                                    i
2126                                );
2127                            }
2128                        }
2129                    }
2130
2131                    Ok(())
2132                },
2133            )
2134            .unwrap();
2135
2136        Ok(())
2137    }
2138
2139    generate_all_coppock_tests!(
2140        check_coppock_partial_params,
2141        check_coppock_accuracy,
2142        check_coppock_default_candles,
2143        check_coppock_zero_period,
2144        check_coppock_period_exceeds_length,
2145        check_coppock_very_small_dataset,
2146        check_coppock_reinput,
2147        check_coppock_nan_handling,
2148        check_coppock_streaming,
2149        check_coppock_no_poison
2150    );
2151
2152    #[cfg(feature = "proptest")]
2153    generate_all_coppock_tests!(check_coppock_property);
2154    fn check_batch_default_row(test: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
2155        skip_if_unsupported!(kernel, test);
2156        let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
2157        let c = read_candles_from_csv(file)?;
2158        let output = CoppockBatchBuilder::new()
2159            .kernel(kernel)
2160            .apply_candles(&c, "close")?;
2161        let def = CoppockParams::default();
2162        let row = output.values_for(&def).expect("default row missing");
2163        assert_eq!(row.len(), c.close.len());
2164
2165        let expected = [
2166            -1.4542764618985533,
2167            -1.3795224034983653,
2168            -1.614331648987457,
2169            -1.9179048338714915,
2170            -2.1096548435774625,
2171        ];
2172        let start = row.len() - 5;
2173        for (i, &v) in row[start..].iter().enumerate() {
2174            assert!(
2175                (v - expected[i]).abs() < 1e-7,
2176                "[{test}] default-row mismatch at idx {i}: {v} vs {expected:?}"
2177            );
2178        }
2179        Ok(())
2180    }
2181
2182    #[cfg(debug_assertions)]
2183    fn check_batch_no_poison(test: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
2184        skip_if_unsupported!(kernel, test);
2185
2186        let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
2187        let c = read_candles_from_csv(file)?;
2188
2189        let output = CoppockBatchBuilder::new()
2190            .kernel(kernel)
2191            .short_range(5, 15, 5)
2192            .long_range(10, 20, 5)
2193            .ma_range(3, 9, 3)
2194            .apply_candles(&c, "close")?;
2195
2196        for (idx, &val) in output.values.iter().enumerate() {
2197            if val.is_nan() {
2198                continue;
2199            }
2200
2201            let bits = val.to_bits();
2202            let row = idx / output.cols;
2203            let col = idx % output.cols;
2204
2205            if bits == 0x11111111_11111111 {
2206                panic!(
2207					"[{}] Found alloc_with_nan_prefix poison value {} (0x{:016X}) at row {} col {} (flat index {})",
2208					test, val, bits, row, col, idx
2209				);
2210            }
2211
2212            if bits == 0x22222222_22222222 {
2213                panic!(
2214					"[{}] Found init_matrix_prefixes poison value {} (0x{:016X}) at row {} col {} (flat index {})",
2215					test, val, bits, row, col, idx
2216				);
2217            }
2218
2219            if bits == 0x33333333_33333333 {
2220                panic!(
2221					"[{}] Found make_uninit_matrix poison value {} (0x{:016X}) at row {} col {} (flat index {})",
2222					test, val, bits, row, col, idx
2223				);
2224            }
2225        }
2226
2227        Ok(())
2228    }
2229
2230    #[cfg(not(debug_assertions))]
2231    fn check_batch_no_poison(_test: &str, _kernel: Kernel) -> Result<(), Box<dyn Error>> {
2232        Ok(())
2233    }
2234
2235    macro_rules! gen_batch_tests {
2236        ($fn_name:ident) => {
2237            paste::paste! {
2238                #[test] fn [<$fn_name _scalar>]()      {
2239                    let _ = $fn_name(stringify!([<$fn_name _scalar>]), Kernel::ScalarBatch);
2240                }
2241                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
2242                #[test] fn [<$fn_name _avx2>]()        {
2243                    let _ = $fn_name(stringify!([<$fn_name _avx2>]), Kernel::Avx2Batch);
2244                }
2245                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
2246                #[test] fn [<$fn_name _avx512>]()      {
2247                    let _ = $fn_name(stringify!([<$fn_name _avx512>]), Kernel::Avx512Batch);
2248                }
2249                #[test] fn [<$fn_name _auto_detect>]() {
2250                    let _ = $fn_name(stringify!([<$fn_name _auto_detect>]), Kernel::Auto);
2251                }
2252            }
2253        };
2254    }
2255    gen_batch_tests!(check_batch_default_row);
2256    gen_batch_tests!(check_batch_no_poison);
2257}
2258
2259#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2260#[wasm_bindgen]
2261pub fn coppock_js(
2262    data: &[f64],
2263    short_roc: usize,
2264    long_roc: usize,
2265    ma_period: usize,
2266    ma_type: &str,
2267) -> Result<Vec<f64>, JsValue> {
2268    let params = CoppockParams {
2269        short_roc_period: Some(short_roc),
2270        long_roc_period: Some(long_roc),
2271        ma_period: Some(ma_period),
2272        ma_type: Some(ma_type.to_string()),
2273    };
2274    let input = CoppockInput::from_slice(data, params);
2275    let mut out = vec![0.0; data.len()];
2276    coppock_into_slice(&mut out, &input, detect_best_kernel()).map_err(JsValue::from)?;
2277    Ok(out)
2278}
2279
2280#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2281#[derive(Serialize, Deserialize)]
2282pub struct CoppockBatchConfig {
2283    pub short_range: (usize, usize, usize),
2284    pub long_range: (usize, usize, usize),
2285    pub ma_range: (usize, usize, usize),
2286    pub ma_type: Option<String>,
2287}
2288
2289#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2290#[derive(Serialize, Deserialize)]
2291pub struct CoppockBatchJsOutput {
2292    pub values: Vec<f64>,
2293    pub combos: Vec<CoppockParams>,
2294    pub rows: usize,
2295    pub cols: usize,
2296}
2297
2298#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2299#[wasm_bindgen(js_name = coppock_batch)]
2300pub fn coppock_batch_unified_js(data: &[f64], config: JsValue) -> Result<JsValue, JsValue> {
2301    let cfg: CoppockBatchConfig = serde_wasm_bindgen::from_value(config)
2302        .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
2303    let sweep = CoppockBatchRange {
2304        short: cfg.short_range,
2305        long: cfg.long_range,
2306        ma: cfg.ma_range,
2307    };
2308    let out =
2309        coppock_batch_inner(data, &sweep, detect_best_kernel(), false).map_err(JsValue::from)?;
2310    let js = CoppockBatchJsOutput {
2311        values: out.values,
2312        combos: out.combos,
2313        rows: out.rows,
2314        cols: out.cols,
2315    };
2316    serde_wasm_bindgen::to_value(&js)
2317        .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
2318}
2319
2320#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2321#[wasm_bindgen]
2322pub fn coppock_alloc(len: usize) -> *mut f64 {
2323    let mut vec = Vec::<f64>::with_capacity(len);
2324    let ptr = vec.as_mut_ptr();
2325    std::mem::forget(vec);
2326    ptr
2327}
2328
2329#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2330#[wasm_bindgen]
2331pub fn coppock_free(ptr: *mut f64, len: usize) {
2332    unsafe {
2333        let _ = Vec::from_raw_parts(ptr, len, len);
2334    }
2335}
2336
2337#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2338#[wasm_bindgen]
2339pub fn coppock_into(
2340    in_ptr: *const f64,
2341    out_ptr: *mut f64,
2342    len: usize,
2343    short_roc: usize,
2344    long_roc: usize,
2345    ma_period: usize,
2346    ma_type: &str,
2347) -> Result<(), JsValue> {
2348    if in_ptr.is_null() || out_ptr.is_null() {
2349        return Err(JsValue::from_str("null pointer"));
2350    }
2351
2352    if short_roc == 0 || long_roc == 0 || ma_period == 0 {
2353        return Err(JsValue::from_str("Invalid period"));
2354    }
2355
2356    let max_period = short_roc.max(long_roc).max(ma_period);
2357    if max_period > len {
2358        return Err(JsValue::from_str("Period exceeds data length"));
2359    }
2360
2361    unsafe {
2362        let data = std::slice::from_raw_parts(in_ptr, len);
2363        let params = CoppockParams {
2364            short_roc_period: Some(short_roc),
2365            long_roc_period: Some(long_roc),
2366            ma_period: Some(ma_period),
2367            ma_type: Some(ma_type.to_string()),
2368        };
2369        let input = CoppockInput::from_slice(data, params);
2370
2371        if in_ptr == out_ptr {
2372            let mut tmp = vec![0.0; len];
2373            coppock_into_slice(&mut tmp, &input, detect_best_kernel()).map_err(JsValue::from)?;
2374            let out = std::slice::from_raw_parts_mut(out_ptr, len);
2375            out.copy_from_slice(&tmp);
2376        } else {
2377            let out = std::slice::from_raw_parts_mut(out_ptr, len);
2378            coppock_into_slice(out, &input, detect_best_kernel()).map_err(JsValue::from)?;
2379        }
2380    }
2381    Ok(())
2382}
2383
2384#[cfg(feature = "python")]
2385use crate::utilities::kernel_validation::validate_kernel;
2386#[cfg(feature = "python")]
2387use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
2388#[cfg(feature = "python")]
2389use pyo3::exceptions::PyValueError;
2390#[cfg(feature = "python")]
2391use pyo3::prelude::*;
2392#[cfg(feature = "python")]
2393use pyo3::types::PyDict;
2394
2395#[cfg(feature = "python")]
2396#[pyfunction(name = "coppock")]
2397#[pyo3(signature = (data, short_roc_period, long_roc_period, ma_period, ma_type=None, kernel=None))]
2398pub fn coppock_py<'py>(
2399    py: Python<'py>,
2400    data: PyReadonlyArray1<'py, f64>,
2401    short_roc_period: usize,
2402    long_roc_period: usize,
2403    ma_period: usize,
2404    ma_type: Option<&str>,
2405    kernel: Option<&str>,
2406) -> PyResult<Bound<'py, PyArray1<f64>>> {
2407    let slice_in = data.as_slice()?;
2408    let kern = validate_kernel(kernel, false)?;
2409
2410    let params = CoppockParams {
2411        short_roc_period: Some(short_roc_period),
2412        long_roc_period: Some(long_roc_period),
2413        ma_period: Some(ma_period),
2414        ma_type: ma_type
2415            .map(|s| s.to_string())
2416            .or_else(|| Some("wma".to_string())),
2417    };
2418    let input = CoppockInput::from_slice(slice_in, params);
2419
2420    let result_vec: Vec<f64> = py
2421        .allow_threads(|| coppock_with_kernel(&input, kern).map(|o| o.values))
2422        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2423
2424    Ok(result_vec.into_pyarray(py))
2425}
2426
2427#[cfg(feature = "python")]
2428#[pyclass(name = "CoppockStream")]
2429pub struct CoppockStreamPy {
2430    stream: CoppockStream,
2431}
2432
2433#[cfg(feature = "python")]
2434#[pymethods]
2435impl CoppockStreamPy {
2436    #[new]
2437    fn new(
2438        short_roc_period: usize,
2439        long_roc_period: usize,
2440        ma_period: usize,
2441        ma_type: Option<&str>,
2442    ) -> PyResult<Self> {
2443        let params = CoppockParams {
2444            short_roc_period: Some(short_roc_period),
2445            long_roc_period: Some(long_roc_period),
2446            ma_period: Some(ma_period),
2447            ma_type: ma_type
2448                .map(|s| s.to_string())
2449                .or_else(|| Some("wma".to_string())),
2450        };
2451        let stream =
2452            CoppockStream::try_new(params).map_err(|e| PyValueError::new_err(e.to_string()))?;
2453        Ok(CoppockStreamPy { stream })
2454    }
2455
2456    fn update(&mut self, value: f64) -> Option<f64> {
2457        self.stream.update(value)
2458    }
2459}
2460
2461#[cfg(feature = "python")]
2462#[pyfunction(name = "coppock_batch")]
2463#[pyo3(signature = (data, short_range, long_range, ma_range, ma_type=None, kernel=None))]
2464pub fn coppock_batch_py<'py>(
2465    py: Python<'py>,
2466    data: PyReadonlyArray1<'py, f64>,
2467    short_range: (usize, usize, usize),
2468    long_range: (usize, usize, usize),
2469    ma_range: (usize, usize, usize),
2470    ma_type: Option<&str>,
2471    kernel: Option<&str>,
2472) -> PyResult<Bound<'py, PyDict>> {
2473    let slice_in = data.as_slice()?;
2474    let kern = validate_kernel(kernel, true)?;
2475
2476    let sweep = CoppockBatchRange {
2477        short: short_range,
2478        long: long_range,
2479        ma: ma_range,
2480    };
2481
2482    let combos = expand_grid(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
2483    let rows = combos.len();
2484    let cols = slice_in.len();
2485
2486    let out_arr = unsafe { PyArray1::<f64>::new(py, [rows * cols], false) };
2487    let slice_out = unsafe { out_arr.as_slice_mut()? };
2488
2489    let combos = py
2490        .allow_threads(|| {
2491            let kernel = match kern {
2492                Kernel::Auto => detect_best_batch_kernel(),
2493                k => k,
2494            };
2495
2496            let simd = match kernel {
2497                Kernel::Avx512Batch => Kernel::Avx512,
2498                Kernel::Avx2Batch => Kernel::Avx2,
2499                Kernel::ScalarBatch => Kernel::Scalar,
2500                _ => kernel,
2501            };
2502
2503            let mut filled_combos = combos.clone();
2504            if let Some(mt) = ma_type {
2505                for combo in &mut filled_combos {
2506                    combo.ma_type = Some(mt.to_string());
2507                }
2508            }
2509
2510            coppock_batch_inner_into(slice_in, &sweep, simd, true, slice_out)?;
2511            Ok::<Vec<CoppockParams>, CoppockError>(filled_combos)
2512        })
2513        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2514
2515    let dict = PyDict::new(py);
2516    dict.set_item("values", out_arr.reshape((rows, cols))?)?;
2517    dict.set_item(
2518        "shorts",
2519        combos
2520            .iter()
2521            .map(|p| p.short_roc_period.unwrap() as u64)
2522            .collect::<Vec<_>>()
2523            .into_pyarray(py),
2524    )?;
2525    dict.set_item(
2526        "longs",
2527        combos
2528            .iter()
2529            .map(|p| p.long_roc_period.unwrap() as u64)
2530            .collect::<Vec<_>>()
2531            .into_pyarray(py),
2532    )?;
2533    dict.set_item(
2534        "ma_periods",
2535        combos
2536            .iter()
2537            .map(|p| p.ma_period.unwrap() as u64)
2538            .collect::<Vec<_>>()
2539            .into_pyarray(py),
2540    )?;
2541    dict.set_item(
2542        "ma_types",
2543        combos
2544            .iter()
2545            .map(|p| p.ma_type.as_deref().unwrap_or("wma"))
2546            .collect::<Vec<_>>(),
2547    )?;
2548
2549    Ok(dict)
2550}
2551
2552#[cfg(all(feature = "python", feature = "cuda"))]
2553use crate::cuda::cuda_available;
2554#[cfg(all(feature = "python", feature = "cuda"))]
2555use crate::cuda::oscillators::coppock_wrapper::{CudaCoppock, DeviceArrayF32Coppock};
2556
2557#[cfg(all(feature = "python", feature = "cuda"))]
2558#[pyclass(module = "ta_indicators.cuda", unsendable)]
2559pub struct CoppockDeviceArrayF32Py {
2560    pub(crate) inner: DeviceArrayF32Coppock,
2561}
2562
2563#[cfg(all(feature = "python", feature = "cuda"))]
2564#[pymethods]
2565impl CoppockDeviceArrayF32Py {
2566    #[getter]
2567    fn __cuda_array_interface__<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
2568        let d = PyDict::new(py);
2569        d.set_item("shape", (self.inner.rows, self.inner.cols))?;
2570        d.set_item("typestr", "<f4")?;
2571        d.set_item(
2572            "strides",
2573            (
2574                self.inner.cols * std::mem::size_of::<f32>(),
2575                std::mem::size_of::<f32>(),
2576            ),
2577        )?;
2578        d.set_item("data", (self.inner.device_ptr() as usize, false))?;
2579
2580        d.set_item("version", 3)?;
2581        Ok(d)
2582    }
2583
2584    fn __dlpack_device__(&self) -> (i32, i32) {
2585        (2, self.inner.device_id as i32)
2586    }
2587
2588    #[pyo3(signature = (stream=None, max_version=None, dl_device=None, copy=None))]
2589    fn __dlpack__<'py>(
2590        &mut self,
2591        py: Python<'py>,
2592        stream: Option<pyo3::PyObject>,
2593        max_version: Option<pyo3::PyObject>,
2594        dl_device: Option<pyo3::PyObject>,
2595        copy: Option<pyo3::PyObject>,
2596    ) -> PyResult<PyObject> {
2597        use crate::utilities::dlpack_cuda::export_f32_cuda_dlpack_2d;
2598        use cust::memory::DeviceBuffer;
2599
2600        let (kdl, alloc_dev) = self.__dlpack_device__();
2601        if let Some(dev_obj) = dl_device.as_ref() {
2602            if let Ok((dev_ty, dev_id)) = dev_obj.extract::<(i32, i32)>(py) {
2603                if dev_ty != kdl || dev_id != alloc_dev {
2604                    let wants_copy = copy
2605                        .as_ref()
2606                        .and_then(|c| c.extract::<bool>(py).ok())
2607                        .unwrap_or(false);
2608                    if wants_copy {
2609                        return Err(PyValueError::new_err(
2610                            "device copy not implemented for __dlpack__",
2611                        ));
2612                    } else {
2613                        return Err(PyValueError::new_err("dl_device mismatch for __dlpack__"));
2614                    }
2615                }
2616            }
2617        }
2618
2619        if let Some(s) = stream.as_ref() {
2620            if let Ok(i) = s.extract::<i64>(py) {
2621                if i == 0 {
2622                    return Err(PyValueError::new_err(
2623                        "__dlpack__: stream 0 is disallowed for CUDA",
2624                    ));
2625                }
2626            }
2627        }
2628
2629        let dummy =
2630            DeviceBuffer::from_slice(&[]).map_err(|e| PyValueError::new_err(e.to_string()))?;
2631        let ctx_clone = self.inner.ctx.clone();
2632        let device_id = self.inner.device_id;
2633        let inner = std::mem::replace(
2634            &mut self.inner,
2635            DeviceArrayF32Coppock {
2636                buf: dummy,
2637                rows: 0,
2638                cols: 0,
2639                ctx: ctx_clone,
2640                device_id,
2641            },
2642        );
2643
2644        let rows = inner.rows;
2645        let cols = inner.cols;
2646        let buf = inner.buf;
2647
2648        let max_version_bound = max_version.map(|obj| obj.into_bound(py));
2649
2650        export_f32_cuda_dlpack_2d(py, buf, rows, cols, alloc_dev, max_version_bound)
2651    }
2652}
2653
2654#[cfg(all(feature = "python", feature = "cuda"))]
2655#[pyfunction(name = "coppock_cuda_batch_dev")]
2656#[pyo3(signature = (data, short_range, long_range, ma_range, device_id=0))]
2657pub fn coppock_cuda_batch_dev_py(
2658    py: Python<'_>,
2659    data: numpy::PyReadonlyArray1<'_, f64>,
2660    short_range: (usize, usize, usize),
2661    long_range: (usize, usize, usize),
2662    ma_range: (usize, usize, usize),
2663    device_id: usize,
2664) -> PyResult<CoppockDeviceArrayF32Py> {
2665    if !cuda_available() {
2666        return Err(PyValueError::new_err("CUDA not available"));
2667    }
2668    let price = data.as_slice()?;
2669    let price_f32: Vec<f32> = price.iter().map(|&v| v as f32).collect();
2670    let sweep = CoppockBatchRange {
2671        short: short_range,
2672        long: long_range,
2673        ma: ma_range,
2674    };
2675    let inner = py.allow_threads(|| {
2676        let cuda = CudaCoppock::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
2677        cuda.coppock_batch_dev(&price_f32, &sweep)
2678            .map_err(|e| PyValueError::new_err(e.to_string()))
2679    })?;
2680    Ok(CoppockDeviceArrayF32Py { inner })
2681}
2682
2683#[cfg(all(feature = "python", feature = "cuda"))]
2684#[pyfunction(name = "coppock_cuda_many_series_one_param_dev")]
2685#[pyo3(signature = (data_tm, cols, rows, short_period, long_period, ma_period, device_id=0))]
2686pub fn coppock_cuda_many_series_one_param_dev_py(
2687    py: Python<'_>,
2688    data_tm: numpy::PyReadonlyArray1<'_, f64>,
2689    cols: usize,
2690    rows: usize,
2691    short_period: usize,
2692    long_period: usize,
2693    ma_period: usize,
2694    device_id: usize,
2695) -> PyResult<CoppockDeviceArrayF32Py> {
2696    if !cuda_available() {
2697        return Err(PyValueError::new_err("CUDA not available"));
2698    }
2699    let slice = data_tm.as_slice()?;
2700    let expected = cols
2701        .checked_mul(rows)
2702        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
2703    if slice.len() != expected {
2704        return Err(PyValueError::new_err("time-major input length mismatch"));
2705    }
2706    let price_f32: Vec<f32> = slice.iter().map(|&v| v as f32).collect();
2707    let inner = py.allow_threads(|| {
2708        let cuda = CudaCoppock::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
2709        cuda.coppock_many_series_one_param_time_major_dev(
2710            &price_f32,
2711            cols,
2712            rows,
2713            short_period,
2714            long_period,
2715            ma_period,
2716        )
2717        .map_err(|e| PyValueError::new_err(e.to_string()))
2718    })?;
2719    Ok(CoppockDeviceArrayF32Py { inner })
2720}