Skip to main content

vector_ta/indicators/
donchian.rs

1use crate::utilities::data_loader::{source_type, Candles};
2use crate::utilities::enums::Kernel;
3#[cfg(target_arch = "wasm32")]
4use crate::utilities::helpers::detect_wasm_kernel;
5use crate::utilities::helpers::{
6    alloc_with_nan_prefix, detect_best_batch_kernel, detect_best_kernel, init_matrix_prefixes,
7    make_uninit_matrix,
8};
9use aligned_vec::{AVec, CACHELINE_ALIGN};
10#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
11use core::arch::x86_64::*;
12#[cfg(not(target_arch = "wasm32"))]
13use rayon::prelude::*;
14#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
15use serde::{Deserialize, Serialize};
16use std::collections::VecDeque;
17use thiserror::Error;
18#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
19use wasm_bindgen::prelude::*;
20
21#[derive(Debug, Clone)]
22pub enum DonchianData<'a> {
23    Candles { candles: &'a Candles },
24    Slices { high: &'a [f64], low: &'a [f64] },
25}
26
27#[derive(Debug, Clone)]
28pub struct DonchianOutput {
29    pub upperband: Vec<f64>,
30    pub middleband: Vec<f64>,
31    pub lowerband: Vec<f64>,
32}
33
34#[derive(Debug, Clone)]
35pub struct DonchianParams {
36    pub period: Option<usize>,
37}
38
39impl Default for DonchianParams {
40    fn default() -> Self {
41        Self { period: Some(20) }
42    }
43}
44
45#[derive(Debug, Clone)]
46pub struct DonchianInput<'a> {
47    pub data: DonchianData<'a>,
48    pub params: DonchianParams,
49}
50
51impl<'a> DonchianInput<'a> {
52    #[inline]
53    pub fn from_candles(candles: &'a Candles, params: DonchianParams) -> Self {
54        Self {
55            data: DonchianData::Candles { candles },
56            params,
57        }
58    }
59    #[inline]
60    pub fn from_slices(high: &'a [f64], low: &'a [f64], params: DonchianParams) -> Self {
61        Self {
62            data: DonchianData::Slices { high, low },
63            params,
64        }
65    }
66    #[inline]
67    pub fn with_default_candles(candles: &'a Candles) -> Self {
68        Self::from_candles(candles, DonchianParams::default())
69    }
70    #[inline]
71    pub fn get_period(&self) -> usize {
72        self.params.period.unwrap_or(20)
73    }
74}
75
76#[derive(Copy, Clone, Debug)]
77pub struct DonchianBuilder {
78    period: Option<usize>,
79    kernel: Kernel,
80}
81
82impl Default for DonchianBuilder {
83    fn default() -> Self {
84        Self {
85            period: None,
86            kernel: Kernel::Auto,
87        }
88    }
89}
90
91impl DonchianBuilder {
92    #[inline(always)]
93    pub fn new() -> Self {
94        Self::default()
95    }
96    #[inline(always)]
97    pub fn period(mut self, n: usize) -> Self {
98        self.period = Some(n);
99        self
100    }
101    #[inline(always)]
102    pub fn kernel(mut self, k: Kernel) -> Self {
103        self.kernel = k;
104        self
105    }
106    #[inline(always)]
107    pub fn apply(self, c: &Candles) -> Result<DonchianOutput, DonchianError> {
108        let p = DonchianParams {
109            period: self.period,
110        };
111        let i = DonchianInput::from_candles(c, p);
112        donchian_with_kernel(&i, self.kernel)
113    }
114    #[inline(always)]
115    pub fn apply_slices(self, high: &[f64], low: &[f64]) -> Result<DonchianOutput, DonchianError> {
116        let p = DonchianParams {
117            period: self.period,
118        };
119        let i = DonchianInput::from_slices(high, low, p);
120        donchian_with_kernel(&i, self.kernel)
121    }
122    #[inline(always)]
123    pub fn into_stream(self) -> Result<DonchianStream, DonchianError> {
124        let p = DonchianParams {
125            period: self.period,
126        };
127        DonchianStream::try_new(p)
128    }
129}
130
131#[derive(Debug, Error)]
132pub enum DonchianError {
133    #[error("donchian: Empty data provided.")]
134    EmptyInputData,
135    #[error("donchian: Invalid period: period = {period}, data length = {data_len}")]
136    InvalidPeriod { period: usize, data_len: usize },
137    #[error("donchian: Not enough valid data: needed = {needed}, valid = {valid}")]
138    NotEnoughValidData { needed: usize, valid: usize },
139    #[error("donchian: All values are NaN.")]
140    AllValuesNaN,
141    #[error("donchian: High/Low data slices have different lengths.")]
142    MismatchedLength,
143    #[error("donchian: Output length mismatch: expected={expected}, got={got}")]
144    OutputLengthMismatch { expected: usize, got: usize },
145    #[error("donchian: invalid range expansion: start={start} end={end} step={step}")]
146    InvalidRange {
147        start: usize,
148        end: usize,
149        step: usize,
150    },
151    #[error("donchian: invalid input: {0}")]
152    InvalidInput(String),
153    #[error("donchian: Invalid kernel for batch: {0:?}")]
154    InvalidKernelForBatch(Kernel),
155}
156
157#[inline]
158pub fn donchian(input: &DonchianInput) -> Result<DonchianOutput, DonchianError> {
159    donchian_with_kernel(input, Kernel::Auto)
160}
161
162pub fn donchian_with_kernel(
163    input: &DonchianInput,
164    kernel: Kernel,
165) -> Result<DonchianOutput, DonchianError> {
166    let (high, low): (&[f64], &[f64]) = match &input.data {
167        DonchianData::Candles { candles } => {
168            let high = source_type(candles, "high");
169            let low = source_type(candles, "low");
170            (high, low)
171        }
172        DonchianData::Slices { high, low } => (high, low),
173    };
174
175    if high.is_empty() || low.is_empty() {
176        return Err(DonchianError::EmptyInputData);
177    }
178    if high.len() != low.len() {
179        return Err(DonchianError::MismatchedLength);
180    }
181
182    let first_valid_high = high.iter().position(|&x| !x.is_nan());
183    let first_valid_low = low.iter().position(|&x| !x.is_nan());
184    let first_valid_idx = match (first_valid_high, first_valid_low) {
185        (Some(h), Some(l)) => h.max(l),
186        _ => return Err(DonchianError::AllValuesNaN),
187    };
188
189    let len = high.len();
190    let period = input.get_period();
191
192    if period == 0 || period > len {
193        return Err(DonchianError::InvalidPeriod {
194            period,
195            data_len: len,
196        });
197    }
198    if (len - first_valid_idx) < period {
199        return Err(DonchianError::NotEnoughValidData {
200            needed: period,
201            valid: len - first_valid_idx,
202        });
203    }
204
205    let chosen = match kernel {
206        Kernel::Auto => Kernel::Scalar,
207        k => k,
208    };
209
210    let warmup_period = first_valid_idx + period - 1;
211    let mut upperband = alloc_with_nan_prefix(len, warmup_period);
212    let mut middleband = alloc_with_nan_prefix(len, warmup_period);
213    let mut lowerband = alloc_with_nan_prefix(len, warmup_period);
214
215    unsafe {
216        match chosen {
217            Kernel::Scalar | Kernel::ScalarBatch => donchian_scalar(
218                high,
219                low,
220                period,
221                first_valid_idx,
222                &mut upperband,
223                &mut middleband,
224                &mut lowerband,
225            ),
226            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
227            Kernel::Avx2 | Kernel::Avx2Batch => donchian_avx2(
228                high,
229                low,
230                period,
231                first_valid_idx,
232                &mut upperband,
233                &mut middleband,
234                &mut lowerband,
235            ),
236            #[cfg(not(all(feature = "nightly-avx", target_arch = "x86_64")))]
237            Kernel::Avx2 | Kernel::Avx2Batch => donchian_scalar(
238                high,
239                low,
240                period,
241                first_valid_idx,
242                &mut upperband,
243                &mut middleband,
244                &mut lowerband,
245            ),
246            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
247            Kernel::Avx512 | Kernel::Avx512Batch => donchian_avx512(
248                high,
249                low,
250                period,
251                first_valid_idx,
252                &mut upperband,
253                &mut middleband,
254                &mut lowerband,
255            ),
256            #[cfg(not(all(feature = "nightly-avx", target_arch = "x86_64")))]
257            Kernel::Avx512 | Kernel::Avx512Batch => donchian_scalar(
258                high,
259                low,
260                period,
261                first_valid_idx,
262                &mut upperband,
263                &mut middleband,
264                &mut lowerband,
265            ),
266            _ => unreachable!(),
267        }
268    }
269
270    Ok(DonchianOutput {
271        upperband,
272        middleband,
273        lowerband,
274    })
275}
276
277#[inline]
278pub fn donchian_scalar(
279    high: &[f64],
280    low: &[f64],
281    period: usize,
282    first_valid: usize,
283    upper: &mut [f64],
284    middle: &mut [f64],
285    lower: &mut [f64],
286) {
287    let n = high.len();
288    if n == 0 || period == 0 {
289        return;
290    }
291    debug_assert_eq!(low.len(), n);
292    debug_assert_eq!(upper.len(), n);
293    debug_assert_eq!(middle.len(), n);
294    debug_assert_eq!(lower.len(), n);
295
296    let warmup = first_valid + period - 1;
297
298    if period == 1 {
299        let start = warmup;
300        unsafe {
301            let hp = high.as_ptr();
302            let lp = low.as_ptr();
303            let up = upper.as_mut_ptr();
304            let mp = middle.as_mut_ptr();
305            let lw = lower.as_mut_ptr();
306            for i in start..n {
307                let h = *hp.add(i);
308                let l = *lp.add(i);
309                if h.is_nan() || l.is_nan() {
310                    *up.add(i) = f64::NAN;
311                    *lw.add(i) = f64::NAN;
312                    *mp.add(i) = f64::NAN;
313                } else {
314                    *up.add(i) = h;
315                    *lw.add(i) = l;
316                    *mp.add(i) = (h - l).mul_add(0.5, l);
317                }
318            }
319        }
320        return;
321    }
322
323    if period <= 32 {
324        unsafe {
325            let hp = high.as_ptr();
326            let lp = low.as_ptr();
327            let up = upper.as_mut_ptr();
328            let mp = middle.as_mut_ptr();
329            let lw = lower.as_mut_ptr();
330            for i in warmup..n {
331                let start = i + 1 - period;
332                let mut maxv = f64::NEG_INFINITY;
333                let mut minv = f64::INFINITY;
334                let mut has_nan = false;
335                for k in 0..period {
336                    let h = *hp.add(start + k);
337                    let l = *lp.add(start + k);
338                    if h.is_nan() || l.is_nan() {
339                        has_nan = true;
340                        break;
341                    }
342                    if h > maxv {
343                        maxv = h;
344                    }
345                    if l < minv {
346                        minv = l;
347                    }
348                }
349                if has_nan {
350                    *up.add(i) = f64::NAN;
351                    *lw.add(i) = f64::NAN;
352                    *mp.add(i) = f64::NAN;
353                } else {
354                    *up.add(i) = maxv;
355                    *lw.add(i) = minv;
356                    *mp.add(i) = (maxv - minv).mul_add(0.5, minv);
357                }
358            }
359        }
360        return;
361    }
362
363    if period <= 32 {
364        unsafe {
365            let hp = high.as_ptr();
366            let lp = low.as_ptr();
367            let up = upper.as_mut_ptr();
368            let mp = middle.as_mut_ptr();
369            let lw = lower.as_mut_ptr();
370            for i in warmup..n {
371                let start = i + 1 - period;
372                let mut maxv = f64::NEG_INFINITY;
373                let mut minv = f64::INFINITY;
374                let mut has_nan = false;
375                for k in 0..period {
376                    let h = *hp.add(start + k);
377                    let l = *lp.add(start + k);
378                    if h.is_nan() || l.is_nan() {
379                        has_nan = true;
380                        break;
381                    }
382                    if h > maxv {
383                        maxv = h;
384                    }
385                    if l < minv {
386                        minv = l;
387                    }
388                }
389                if has_nan {
390                    *up.add(i) = f64::NAN;
391                    *lw.add(i) = f64::NAN;
392                    *mp.add(i) = f64::NAN;
393                } else {
394                    *up.add(i) = maxv;
395                    *lw.add(i) = minv;
396                    *mp.add(i) = (maxv - minv).mul_add(0.5, minv);
397                }
398            }
399        }
400        return;
401    }
402
403    let mut g_max = AVec::<f64>::with_capacity(CACHELINE_ALIGN, n);
404    let mut g_min = AVec::<f64>::with_capacity(CACHELINE_ALIGN, n);
405    let mut valid: Vec<u8> = Vec::with_capacity(n);
406    unsafe {
407        g_max.set_len(n);
408        g_min.set_len(n);
409        valid.set_len(n);
410        let hp = high.as_ptr();
411        let lp = low.as_ptr();
412        let gp_max = g_max.as_mut_ptr();
413        let gp_min = g_min.as_mut_ptr();
414        let vp = valid.as_mut_ptr();
415
416        let mut acc_max = f64::NEG_INFINITY;
417        let mut acc_min = f64::INFINITY;
418        let mut k: usize = 0;
419
420        for i in 0..n {
421            let h = *hp.add(i);
422            let l = *lp.add(i);
423            let ok = h.is_finite() & l.is_finite();
424            *vp.add(i) = ok as u8;
425            let hv = if ok { h } else { f64::NEG_INFINITY };
426            let lv = if ok { l } else { f64::INFINITY };
427            if k == 0 {
428                acc_max = hv;
429                acc_min = lv;
430            } else {
431                if hv > acc_max {
432                    acc_max = hv;
433                }
434                if lv < acc_min {
435                    acc_min = lv;
436                }
437            }
438            *gp_max.add(i) = acc_max;
439            *gp_min.add(i) = acc_min;
440            k += 1;
441            if k == period {
442                k = 0;
443            }
444        }
445    }
446
447    let mut ps: Vec<u32> = Vec::with_capacity(n + 1);
448    unsafe {
449        ps.set_len(n + 1);
450        let psp = ps.as_mut_ptr();
451        let vp = valid.as_ptr();
452        *psp.add(0) = 0;
453        for i in 0..n {
454            let prev = *psp.add(i);
455            let add = *vp.add(i) as u32;
456            *psp.add(i + 1) = prev + add;
457        }
458    }
459
460    unsafe {
461        let hp = high.as_ptr();
462        let lp = low.as_ptr();
463        let up = upper.as_mut_ptr();
464        let mp = middle.as_mut_ptr();
465        let lw = lower.as_mut_ptr();
466        let gp_max = g_max.as_ptr();
467        let gp_min = g_min.as_ptr();
468        let psp = ps.as_ptr();
469
470        let mut acc_max = f64::NEG_INFINITY;
471        let mut acc_min = f64::INFINITY;
472
473        for j in (0..n).rev() {
474            let h = *hp.add(j);
475            let l = *lp.add(j);
476            let ok = h.is_finite() & l.is_finite();
477            let hv = if ok { h } else { f64::NEG_INFINITY };
478            let lv = if ok { l } else { f64::INFINITY };
479
480            if j == n - 1 || ((j + 1) % period) == 0 {
481                acc_max = hv;
482                acc_min = lv;
483            } else {
484                if hv > acc_max {
485                    acc_max = hv;
486                }
487                if lv < acc_min {
488                    acc_min = lv;
489                }
490            }
491
492            let i = j + period - 1;
493            if i >= n || i < warmup {
494                continue;
495            }
496
497            let all_valid = {
498                let vcnt = *psp.add(i + 1) - *psp.add(i + 1 - period);
499                vcnt == period as u32
500            };
501            if all_valid {
502                let gm = *gp_max.add(i);
503                let gn = *gp_min.add(i);
504                let maxv = if acc_max > gm { acc_max } else { gm };
505                let minv = if acc_min < gn { acc_min } else { gn };
506                *up.add(i) = maxv;
507                *lw.add(i) = minv;
508                *mp.add(i) = (maxv - minv).mul_add(0.5, minv);
509            } else {
510                *up.add(i) = f64::NAN;
511                *lw.add(i) = f64::NAN;
512                *mp.add(i) = f64::NAN;
513            }
514        }
515    }
516}
517
518#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
519#[inline]
520pub fn donchian_avx512(
521    high: &[f64],
522    low: &[f64],
523    period: usize,
524    first_valid: usize,
525    upper: &mut [f64],
526    middle: &mut [f64],
527    lower: &mut [f64],
528) {
529    donchian_scalar(high, low, period, first_valid, upper, middle, lower)
530}
531
532#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
533#[inline]
534pub fn donchian_avx2(
535    high: &[f64],
536    low: &[f64],
537    period: usize,
538    first_valid: usize,
539    upper: &mut [f64],
540    middle: &mut [f64],
541    lower: &mut [f64],
542) {
543    donchian_scalar(high, low, period, first_valid, upper, middle, lower)
544}
545
546pub fn donchian_into_slice(
547    upper_dst: &mut [f64],
548    middle_dst: &mut [f64],
549    lower_dst: &mut [f64],
550    input: &DonchianInput,
551    kern: Kernel,
552) -> Result<(), DonchianError> {
553    let (high, low): (&[f64], &[f64]) = match &input.data {
554        DonchianData::Candles { candles } => {
555            let high = source_type(candles, "high");
556            let low = source_type(candles, "low");
557            (high, low)
558        }
559        DonchianData::Slices { high, low } => (high, low),
560    };
561
562    if high.is_empty() || low.is_empty() {
563        return Err(DonchianError::EmptyInputData);
564    }
565    if high.len() != low.len() {
566        return Err(DonchianError::MismatchedLength);
567    }
568    if upper_dst.len() != high.len()
569        || middle_dst.len() != high.len()
570        || lower_dst.len() != high.len()
571    {
572        return Err(DonchianError::OutputLengthMismatch {
573            expected: high.len(),
574            got: upper_dst.len().max(middle_dst.len()).max(lower_dst.len()),
575        });
576    }
577
578    let first_valid_high = high.iter().position(|&x| !x.is_nan());
579    let first_valid_low = low.iter().position(|&x| !x.is_nan());
580    let first_valid_idx = match (first_valid_high, first_valid_low) {
581        (Some(h), Some(l)) => h.max(l),
582        _ => return Err(DonchianError::AllValuesNaN),
583    };
584
585    let period = input.get_period();
586    if period == 0 || period > high.len() {
587        return Err(DonchianError::InvalidPeriod {
588            period,
589            data_len: high.len(),
590        });
591    }
592    if (high.len() - first_valid_idx) < period {
593        return Err(DonchianError::NotEnoughValidData {
594            needed: period,
595            valid: high.len() - first_valid_idx,
596        });
597    }
598
599    let chosen = match kern {
600        #[cfg(target_arch = "wasm32")]
601        Kernel::Auto => Kernel::Scalar,
602        #[cfg(not(target_arch = "wasm32"))]
603        Kernel::Auto => Kernel::Scalar,
604        k => k,
605    };
606
607    let warmup_period = first_valid_idx + period - 1;
608
609    for i in 0..warmup_period {
610        upper_dst[i] = f64::NAN;
611        middle_dst[i] = f64::NAN;
612        lower_dst[i] = f64::NAN;
613    }
614
615    unsafe {
616        match chosen {
617            Kernel::Scalar | Kernel::ScalarBatch => donchian_scalar(
618                high,
619                low,
620                period,
621                first_valid_idx,
622                upper_dst,
623                middle_dst,
624                lower_dst,
625            ),
626            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
627            Kernel::Avx2 | Kernel::Avx2Batch => donchian_avx2(
628                high,
629                low,
630                period,
631                first_valid_idx,
632                upper_dst,
633                middle_dst,
634                lower_dst,
635            ),
636            #[cfg(not(all(feature = "nightly-avx", target_arch = "x86_64")))]
637            Kernel::Avx2 | Kernel::Avx2Batch => donchian_scalar(
638                high,
639                low,
640                period,
641                first_valid_idx,
642                upper_dst,
643                middle_dst,
644                lower_dst,
645            ),
646            #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
647            Kernel::Avx512 | Kernel::Avx512Batch => donchian_avx512(
648                high,
649                low,
650                period,
651                first_valid_idx,
652                upper_dst,
653                middle_dst,
654                lower_dst,
655            ),
656            #[cfg(not(all(feature = "nightly-avx", target_arch = "x86_64")))]
657            Kernel::Avx512 | Kernel::Avx512Batch => donchian_scalar(
658                high,
659                low,
660                period,
661                first_valid_idx,
662                upper_dst,
663                middle_dst,
664                lower_dst,
665            ),
666            _ => unreachable!(),
667        }
668    }
669
670    Ok(())
671}
672
673#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
674#[inline]
675pub fn donchian_into(
676    input: &DonchianInput,
677    upper: &mut [f64],
678    middle: &mut [f64],
679    lower: &mut [f64],
680) -> Result<(), DonchianError> {
681    donchian_into_slice(upper, middle, lower, input, Kernel::Auto)
682}
683
684#[inline(always)]
685pub fn donchian_batch_with_kernel(
686    high: &[f64],
687    low: &[f64],
688    sweep: &DonchianBatchRange,
689    k: Kernel,
690) -> Result<DonchianBatchOutput, DonchianError> {
691    let kernel = match k {
692        Kernel::Auto => detect_best_batch_kernel(),
693        other if other.is_batch() => other,
694        other => return Err(DonchianError::InvalidKernelForBatch(other)),
695    };
696    let simd = match kernel {
697        Kernel::Avx512Batch => Kernel::Avx512,
698        Kernel::Avx2Batch => Kernel::Avx2,
699        Kernel::ScalarBatch => Kernel::Scalar,
700        _ => unreachable!(),
701    };
702    donchian_batch_par_slice(high, low, sweep, simd)
703}
704
705#[derive(Clone, Debug)]
706pub struct DonchianBatchRange {
707    pub period: (usize, usize, usize),
708}
709
710impl Default for DonchianBatchRange {
711    fn default() -> Self {
712        Self {
713            period: (20, 269, 1),
714        }
715    }
716}
717
718#[derive(Clone, Debug, Default)]
719pub struct DonchianBatchBuilder {
720    range: DonchianBatchRange,
721    kernel: Kernel,
722}
723
724impl DonchianBatchBuilder {
725    pub fn new() -> Self {
726        Self::default()
727    }
728    pub fn kernel(mut self, k: Kernel) -> Self {
729        self.kernel = k;
730        self
731    }
732    pub fn period_range(mut self, start: usize, end: usize, step: usize) -> Self {
733        self.range.period = (start, end, step);
734        self
735    }
736    pub fn period_static(mut self, p: usize) -> Self {
737        self.range.period = (p, p, 0);
738        self
739    }
740    pub fn apply_slices(
741        self,
742        high: &[f64],
743        low: &[f64],
744    ) -> Result<DonchianBatchOutput, DonchianError> {
745        donchian_batch_with_kernel(high, low, &self.range, self.kernel)
746    }
747    pub fn with_default_slices(
748        high: &[f64],
749        low: &[f64],
750        k: Kernel,
751    ) -> Result<DonchianBatchOutput, DonchianError> {
752        DonchianBatchBuilder::new()
753            .kernel(k)
754            .apply_slices(high, low)
755    }
756    pub fn apply_candles(self, c: &Candles) -> Result<DonchianBatchOutput, DonchianError> {
757        let high = source_type(c, "high");
758        let low = source_type(c, "low");
759        self.apply_slices(high, low)
760    }
761    pub fn with_default_candles(c: &Candles) -> Result<DonchianBatchOutput, DonchianError> {
762        DonchianBatchBuilder::new()
763            .kernel(Kernel::Auto)
764            .apply_candles(c)
765    }
766}
767
768#[derive(Clone, Debug)]
769pub struct DonchianBatchOutput {
770    pub upper: Vec<f64>,
771    pub middle: Vec<f64>,
772    pub lower: Vec<f64>,
773    pub combos: Vec<DonchianParams>,
774    pub rows: usize,
775    pub cols: usize,
776}
777
778impl DonchianBatchOutput {
779    pub fn row_for_params(&self, p: &DonchianParams) -> Option<usize> {
780        self.combos
781            .iter()
782            .position(|c| c.period.unwrap_or(20) == p.period.unwrap_or(20))
783    }
784    pub fn upper_for(&self, p: &DonchianParams) -> Option<&[f64]> {
785        self.row_for_params(p)
786            .map(|row| &self.upper[row * self.cols..][..self.cols])
787    }
788    pub fn middle_for(&self, p: &DonchianParams) -> Option<&[f64]> {
789        self.row_for_params(p)
790            .map(|row| &self.middle[row * self.cols..][..self.cols])
791    }
792    pub fn lower_for(&self, p: &DonchianParams) -> Option<&[f64]> {
793        self.row_for_params(p)
794            .map(|row| &self.lower[row * self.cols..][..self.cols])
795    }
796}
797
798#[inline(always)]
799pub fn expand_grid(r: &DonchianBatchRange) -> Result<Vec<DonchianParams>, DonchianError> {
800    fn axis_usize((start, end, step): (usize, usize, usize)) -> Result<Vec<usize>, DonchianError> {
801        if step == 0 || start == end {
802            return Ok(vec![start]);
803        }
804        if start < end {
805            Ok((start..=end).step_by(step).collect())
806        } else {
807            let mut v = Vec::new();
808            let mut cur = start;
809            while cur >= end {
810                v.push(cur);
811                if let Some(next) = cur.checked_sub(step) {
812                    cur = next;
813                } else {
814                    break;
815                }
816                if cur == usize::MAX {
817                    break;
818                }
819            }
820            if v.is_empty() {
821                return Err(DonchianError::InvalidRange { start, end, step });
822            }
823            Ok(v)
824        }
825    }
826    let periods = axis_usize(r.period)?;
827    Ok(periods
828        .into_iter()
829        .map(|p| DonchianParams { period: Some(p) })
830        .collect())
831}
832
833#[inline(always)]
834pub fn donchian_batch_slice(
835    high: &[f64],
836    low: &[f64],
837    sweep: &DonchianBatchRange,
838    kern: Kernel,
839) -> Result<DonchianBatchOutput, DonchianError> {
840    donchian_batch_inner(high, low, sweep, kern, false)
841}
842
843#[inline(always)]
844pub fn donchian_batch_par_slice(
845    high: &[f64],
846    low: &[f64],
847    sweep: &DonchianBatchRange,
848    kern: Kernel,
849) -> Result<DonchianBatchOutput, DonchianError> {
850    donchian_batch_inner(high, low, sweep, kern, true)
851}
852
853#[inline(always)]
854fn donchian_batch_inner(
855    high: &[f64],
856    low: &[f64],
857    sweep: &DonchianBatchRange,
858    kern: Kernel,
859    parallel: bool,
860) -> Result<DonchianBatchOutput, DonchianError> {
861    let combos = expand_grid(sweep)?;
862    if combos.is_empty() {
863        return Err(DonchianError::InvalidRange {
864            start: sweep.period.0,
865            end: sweep.period.1,
866            step: sweep.period.2,
867        });
868    }
869    if high.len() != low.len() {
870        return Err(DonchianError::MismatchedLength);
871    }
872    let first = high
873        .iter()
874        .position(|x| !x.is_nan())
875        .zip(low.iter().position(|x| !x.is_nan()))
876        .map(|(a, b)| a.max(b));
877    let first = match first {
878        Some(idx) => idx,
879        None => return Err(DonchianError::AllValuesNaN),
880    };
881    let max_p = combos.iter().map(|c| c.period.unwrap()).max().unwrap();
882    if high.len() - first < max_p {
883        return Err(DonchianError::NotEnoughValidData {
884            needed: max_p,
885            valid: high.len() - first,
886        });
887    }
888    let rows = combos.len();
889    let cols = high.len();
890    let _size = rows
891        .checked_mul(cols)
892        .ok_or_else(|| DonchianError::InvalidInput("rows*cols overflow".into()))?;
893
894    let warmup_periods: Vec<usize> = combos
895        .iter()
896        .map(|c| first + c.period.unwrap() - 1)
897        .collect();
898
899    let mut upper_mu = make_uninit_matrix(rows, cols);
900    let mut middle_mu = make_uninit_matrix(rows, cols);
901    let mut lower_mu = make_uninit_matrix(rows, cols);
902
903    init_matrix_prefixes(&mut upper_mu, cols, &warmup_periods);
904    init_matrix_prefixes(&mut middle_mu, cols, &warmup_periods);
905    init_matrix_prefixes(&mut lower_mu, cols, &warmup_periods);
906
907    let mut upper_guard = core::mem::ManuallyDrop::new(upper_mu);
908    let mut middle_guard = core::mem::ManuallyDrop::new(middle_mu);
909    let mut lower_guard = core::mem::ManuallyDrop::new(lower_mu);
910
911    let upper: &mut [f64] = unsafe {
912        core::slice::from_raw_parts_mut(upper_guard.as_mut_ptr() as *mut f64, upper_guard.len())
913    };
914    let middle: &mut [f64] = unsafe {
915        core::slice::from_raw_parts_mut(middle_guard.as_mut_ptr() as *mut f64, middle_guard.len())
916    };
917    let lower: &mut [f64] = unsafe {
918        core::slice::from_raw_parts_mut(lower_guard.as_mut_ptr() as *mut f64, lower_guard.len())
919    };
920
921    let do_row =
922        |row: usize, out_upper: &mut [f64], out_middle: &mut [f64], out_lower: &mut [f64]| unsafe {
923            let period = combos[row].period.unwrap();
924            match kern {
925                Kernel::Scalar => {
926                    donchian_row_scalar(high, low, first, period, out_upper, out_middle, out_lower)
927                }
928                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
929                Kernel::Avx2 => {
930                    donchian_row_avx2(high, low, first, period, out_upper, out_middle, out_lower)
931                }
932                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
933                Kernel::Avx512 => {
934                    donchian_row_avx512(high, low, first, period, out_upper, out_middle, out_lower)
935                }
936                _ => unreachable!(),
937            }
938        };
939
940    if parallel {
941        #[cfg(not(target_arch = "wasm32"))]
942        {
943            upper
944                .par_chunks_mut(cols)
945                .zip(middle.par_chunks_mut(cols))
946                .zip(lower.par_chunks_mut(cols))
947                .enumerate()
948                .for_each(|(row, ((upper, middle), lower))| do_row(row, upper, middle, lower));
949        }
950
951        #[cfg(target_arch = "wasm32")]
952        {
953            for (((upper, middle), lower), row) in upper
954                .chunks_mut(cols)
955                .zip(middle.chunks_mut(cols))
956                .zip(lower.chunks_mut(cols))
957                .zip(0..)
958            {
959                do_row(row, upper, middle, lower);
960            }
961        }
962    } else {
963        for (((upper, middle), lower), row) in upper
964            .chunks_mut(cols)
965            .zip(middle.chunks_mut(cols))
966            .zip(lower.chunks_mut(cols))
967            .zip(0..)
968        {
969            do_row(row, upper, middle, lower);
970        }
971    }
972
973    let upper = unsafe {
974        Vec::from_raw_parts(
975            upper_guard.as_mut_ptr() as *mut f64,
976            upper_guard.len(),
977            upper_guard.capacity(),
978        )
979    };
980    let middle = unsafe {
981        Vec::from_raw_parts(
982            middle_guard.as_mut_ptr() as *mut f64,
983            middle_guard.len(),
984            middle_guard.capacity(),
985        )
986    };
987    let lower = unsafe {
988        Vec::from_raw_parts(
989            lower_guard.as_mut_ptr() as *mut f64,
990            lower_guard.len(),
991            lower_guard.capacity(),
992        )
993    };
994
995    Ok(DonchianBatchOutput {
996        upper,
997        middle,
998        lower,
999        combos,
1000        rows,
1001        cols,
1002    })
1003}
1004
1005#[inline(always)]
1006unsafe fn donchian_row_scalar(
1007    high: &[f64],
1008    low: &[f64],
1009    first: usize,
1010    period: usize,
1011    upper: &mut [f64],
1012    middle: &mut [f64],
1013    lower: &mut [f64],
1014) {
1015    let n = high.len();
1016    if n == 0 || period == 0 {
1017        return;
1018    }
1019    let warmup = first + period - 1;
1020
1021    if period == 1 {
1022        let hp = high.as_ptr();
1023        let lp = low.as_ptr();
1024        let up = upper.as_mut_ptr();
1025        let mp = middle.as_mut_ptr();
1026        let lw = lower.as_mut_ptr();
1027        for i in warmup..n {
1028            let h = *hp.add(i);
1029            let l = *lp.add(i);
1030            if h.is_nan() || l.is_nan() {
1031                *up.add(i) = f64::NAN;
1032                *lw.add(i) = f64::NAN;
1033                *mp.add(i) = f64::NAN;
1034            } else {
1035                *up.add(i) = h;
1036                *lw.add(i) = l;
1037                *mp.add(i) = (h - l).mul_add(0.5, l);
1038            }
1039        }
1040        return;
1041    }
1042
1043    if period <= 32 {
1044        let hp = high.as_ptr();
1045        let lp = low.as_ptr();
1046        let up = upper.as_mut_ptr();
1047        let mp = middle.as_mut_ptr();
1048        let lw = lower.as_mut_ptr();
1049        for i in warmup..n {
1050            let start = i + 1 - period;
1051            let mut maxv = f64::NEG_INFINITY;
1052            let mut minv = f64::INFINITY;
1053            let mut has_nan = false;
1054            for k in 0..period {
1055                let h = *hp.add(start + k);
1056                let l = *lp.add(start + k);
1057                if h.is_nan() || l.is_nan() {
1058                    has_nan = true;
1059                    break;
1060                }
1061                if h > maxv {
1062                    maxv = h;
1063                }
1064                if l < minv {
1065                    minv = l;
1066                }
1067            }
1068            if has_nan {
1069                *up.add(i) = f64::NAN;
1070                *lw.add(i) = f64::NAN;
1071                *mp.add(i) = f64::NAN;
1072            } else {
1073                *up.add(i) = maxv;
1074                *lw.add(i) = minv;
1075                *mp.add(i) = (maxv - minv).mul_add(0.5, minv);
1076            }
1077        }
1078        return;
1079    }
1080
1081    let mut g_max = AVec::<f64>::with_capacity(CACHELINE_ALIGN, n);
1082    let mut g_min = AVec::<f64>::with_capacity(CACHELINE_ALIGN, n);
1083    let mut valid: Vec<u8> = Vec::with_capacity(n);
1084    g_max.set_len(n);
1085    g_min.set_len(n);
1086    valid.set_len(n);
1087    let hp = high.as_ptr();
1088    let lp = low.as_ptr();
1089    let gp_max = g_max.as_mut_ptr();
1090    let gp_min = g_min.as_mut_ptr();
1091    let vp = valid.as_mut_ptr();
1092
1093    let mut acc_max = f64::NEG_INFINITY;
1094    let mut acc_min = f64::INFINITY;
1095    let mut k: usize = 0;
1096    for i in 0..n {
1097        let h = *hp.add(i);
1098        let l = *lp.add(i);
1099        let ok = h.is_finite() & l.is_finite();
1100        *vp.add(i) = ok as u8;
1101        let hv = if ok { h } else { f64::NEG_INFINITY };
1102        let lv = if ok { l } else { f64::INFINITY };
1103        if k == 0 {
1104            acc_max = hv;
1105            acc_min = lv;
1106        } else {
1107            if hv > acc_max {
1108                acc_max = hv;
1109            }
1110            if lv < acc_min {
1111                acc_min = lv;
1112            }
1113        }
1114        *gp_max.add(i) = acc_max;
1115        *gp_min.add(i) = acc_min;
1116        k += 1;
1117        if k == period {
1118            k = 0;
1119        }
1120    }
1121
1122    let up = upper.as_mut_ptr();
1123    let mp = middle.as_mut_ptr();
1124    let lw = lower.as_mut_ptr();
1125    let gp_max = g_max.as_ptr();
1126    let gp_min = g_min.as_ptr();
1127    let vp = valid.as_ptr();
1128
1129    acc_max = f64::NEG_INFINITY;
1130    acc_min = f64::INFINITY;
1131    let mut have_vcnt = false;
1132    let mut vcnt: u32 = 0;
1133    for j in (0..n).rev() {
1134        let h = *hp.add(j);
1135        let l = *lp.add(j);
1136        let ok = h.is_finite() & l.is_finite();
1137        let hv = if ok { h } else { f64::NEG_INFINITY };
1138        let lv = if ok { l } else { f64::INFINITY };
1139
1140        if j == n - 1 || ((j + 1) % period) == 0 {
1141            acc_max = hv;
1142            acc_min = lv;
1143        } else {
1144            if hv > acc_max {
1145                acc_max = hv;
1146            }
1147            if lv < acc_min {
1148                acc_min = lv;
1149            }
1150        }
1151
1152        let i = j + period - 1;
1153        if i < n {
1154            if !have_vcnt {
1155                let start = i + 1 - period;
1156                let mut sum: u32 = 0;
1157                for t in start..=i {
1158                    sum += *vp.add(t) as u32;
1159                }
1160                vcnt = sum;
1161                have_vcnt = true;
1162            } else {
1163                if i + 1 < n {
1164                    vcnt = vcnt - (*vp.add(i + 1) as u32) + (*vp.add(i + 1 - period) as u32);
1165                }
1166            }
1167        }
1168
1169        if i >= n || i < warmup {
1170            continue;
1171        }
1172
1173        let all_valid = vcnt == period as u32;
1174        if all_valid {
1175            let gm = *gp_max.add(i);
1176            let gn = *gp_min.add(i);
1177            let maxv = if acc_max > gm { acc_max } else { gm };
1178            let minv = if acc_min < gn { acc_min } else { gn };
1179            *up.add(i) = maxv;
1180            *lw.add(i) = minv;
1181            *mp.add(i) = (maxv - minv).mul_add(0.5, minv);
1182        } else {
1183            *up.add(i) = f64::NAN;
1184            *lw.add(i) = f64::NAN;
1185            *mp.add(i) = f64::NAN;
1186        }
1187    }
1188}
1189
1190#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1191#[inline(always)]
1192pub unsafe fn donchian_row_avx2(
1193    high: &[f64],
1194    low: &[f64],
1195    first: usize,
1196    period: usize,
1197    upper: &mut [f64],
1198    middle: &mut [f64],
1199    lower: &mut [f64],
1200) {
1201    donchian_row_scalar(high, low, first, period, upper, middle, lower)
1202}
1203
1204#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1205#[inline(always)]
1206pub unsafe fn donchian_row_avx512(
1207    high: &[f64],
1208    low: &[f64],
1209    first: usize,
1210    period: usize,
1211    upper: &mut [f64],
1212    middle: &mut [f64],
1213    lower: &mut [f64],
1214) {
1215    if period <= 32 {
1216        donchian_row_avx512_short(high, low, first, period, upper, middle, lower)
1217    } else {
1218        donchian_row_avx512_long(high, low, first, period, upper, middle, lower)
1219    }
1220}
1221
1222#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1223#[inline(always)]
1224pub unsafe fn donchian_row_avx512_short(
1225    high: &[f64],
1226    low: &[f64],
1227    first: usize,
1228    period: usize,
1229    upper: &mut [f64],
1230    middle: &mut [f64],
1231    lower: &mut [f64],
1232) {
1233    donchian_row_scalar(high, low, first, period, upper, middle, lower)
1234}
1235
1236#[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1237#[inline(always)]
1238pub unsafe fn donchian_row_avx512_long(
1239    high: &[f64],
1240    low: &[f64],
1241    first: usize,
1242    period: usize,
1243    upper: &mut [f64],
1244    middle: &mut [f64],
1245    lower: &mut [f64],
1246) {
1247    donchian_row_scalar(high, low, first, period, upper, middle, lower)
1248}
1249
1250#[derive(Debug, Clone)]
1251pub struct DonchianStream {
1252    period: usize,
1253
1254    valid_ring: Vec<u8>,
1255    head: usize,
1256    seen: usize,
1257    valid_count: usize,
1258
1259    max_deque: VecDeque<(f64, usize)>,
1260    min_deque: VecDeque<(f64, usize)>,
1261}
1262
1263impl DonchianStream {
1264    pub fn try_new(params: DonchianParams) -> Result<Self, DonchianError> {
1265        let period = params.period.unwrap_or(20);
1266        if period == 0 {
1267            return Err(DonchianError::InvalidPeriod {
1268                period,
1269                data_len: 0,
1270            });
1271        }
1272        Ok(Self {
1273            period,
1274            valid_ring: vec![0; period],
1275            head: 0,
1276            seen: 0,
1277            valid_count: 0,
1278            max_deque: VecDeque::with_capacity(period),
1279            min_deque: VecDeque::with_capacity(period),
1280        })
1281    }
1282
1283    #[inline(always)]
1284    fn evict_outdated(&mut self, window_start: usize) {
1285        while let Some(&(_, idx)) = self.max_deque.front() {
1286            if idx < window_start {
1287                self.max_deque.pop_front();
1288            } else {
1289                break;
1290            }
1291        }
1292        while let Some(&(_, idx)) = self.min_deque.front() {
1293            if idx < window_start {
1294                self.min_deque.pop_front();
1295            } else {
1296                break;
1297            }
1298        }
1299    }
1300
1301    #[inline(always)]
1302    pub fn update(&mut self, high: f64, low: f64) -> Option<(f64, f64, f64)> {
1303        let ok = high.is_finite() & low.is_finite();
1304
1305        let leaving = self.valid_ring[self.head] as usize;
1306        self.valid_ring[self.head] = ok as u8;
1307        self.head += 1;
1308        if self.head == self.period {
1309            self.head = 0;
1310        }
1311        self.valid_count = self.valid_count + (ok as usize) - leaving;
1312
1313        let t = self.seen;
1314        self.seen = t + 1;
1315        let window_start = self.seen.saturating_sub(self.period);
1316
1317        self.evict_outdated(window_start);
1318
1319        if ok {
1320            while let Some(&(v, _)) = self.max_deque.back() {
1321                if v <= high {
1322                    self.max_deque.pop_back();
1323                } else {
1324                    break;
1325                }
1326            }
1327            self.max_deque.push_back((high, t));
1328
1329            while let Some(&(v, _)) = self.min_deque.back() {
1330                if v >= low {
1331                    self.min_deque.pop_back();
1332                } else {
1333                    break;
1334                }
1335            }
1336            self.min_deque.push_back((low, t));
1337        }
1338
1339        if self.seen < self.period {
1340            return None;
1341        }
1342
1343        if self.valid_count != self.period {
1344            return Some((f64::NAN, f64::NAN, f64::NAN));
1345        }
1346
1347        debug_assert!(!self.max_deque.is_empty() && !self.min_deque.is_empty());
1348        let maxv = self.max_deque.front().unwrap().0;
1349        let minv = self.min_deque.front().unwrap().0;
1350
1351        let mid = (maxv - minv).mul_add(0.5, minv);
1352        Some((maxv, mid, minv))
1353    }
1354}
1355
1356#[cfg(test)]
1357mod tests {
1358    use super::*;
1359    use crate::skip_if_unsupported;
1360    use crate::utilities::data_loader::read_candles_from_csv;
1361
1362    fn check_donchian_partial_params(
1363        test_name: &str,
1364        kernel: Kernel,
1365    ) -> Result<(), Box<dyn std::error::Error>> {
1366        skip_if_unsupported!(kernel, test_name);
1367        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1368        let candles = read_candles_from_csv(file_path)?;
1369        let default_params = DonchianParams { period: None };
1370        let input = DonchianInput::from_candles(&candles, default_params);
1371        let output = donchian_with_kernel(&input, kernel)?;
1372        assert_eq!(output.upperband.len(), candles.close.len());
1373        Ok(())
1374    }
1375
1376    fn check_donchian_accuracy(
1377        test_name: &str,
1378        kernel: Kernel,
1379    ) -> Result<(), Box<dyn std::error::Error>> {
1380        skip_if_unsupported!(kernel, test_name);
1381        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1382        let candles = read_candles_from_csv(file_path)?;
1383        let params = DonchianParams { period: Some(20) };
1384        let input = DonchianInput::from_candles(&candles, params);
1385        let result = donchian_with_kernel(&input, kernel)?;
1386        let expected_last_five_upper = [61290.0, 61290.0, 61290.0, 61290.0, 61290.0];
1387        let expected_last_five_middle = [59583.0, 59583.0, 59583.0, 59583.0, 59583.0];
1388        let expected_last_five_lower = [57876.0, 57876.0, 57876.0, 57876.0, 57876.0];
1389        let start = result.upperband.len().saturating_sub(5);
1390        for i in 0..5 {
1391            assert!((result.upperband[start + i] - expected_last_five_upper[i]).abs() < 1e-1);
1392            assert!((result.middleband[start + i] - expected_last_five_middle[i]).abs() < 1e-1);
1393            assert!((result.lowerband[start + i] - expected_last_five_lower[i]).abs() < 1e-1);
1394        }
1395        Ok(())
1396    }
1397
1398    fn check_donchian_zero_period(
1399        test_name: &str,
1400        kernel: Kernel,
1401    ) -> Result<(), Box<dyn std::error::Error>> {
1402        skip_if_unsupported!(kernel, test_name);
1403        let high = [10.0, 20.0, 30.0];
1404        let low = [5.0, 3.0, 2.0];
1405        let params = DonchianParams { period: Some(0) };
1406        let input = DonchianInput::from_slices(&high, &low, params);
1407        let res = donchian_with_kernel(&input, kernel);
1408        assert!(res.is_err());
1409        Ok(())
1410    }
1411
1412    fn check_donchian_period_exceeds_length(
1413        test_name: &str,
1414        kernel: Kernel,
1415    ) -> Result<(), Box<dyn std::error::Error>> {
1416        skip_if_unsupported!(kernel, test_name);
1417        let high = [10.0, 20.0, 30.0];
1418        let low = [5.0, 3.0, 2.0];
1419        let params = DonchianParams { period: Some(10) };
1420        let input = DonchianInput::from_slices(&high, &low, params);
1421        let res = donchian_with_kernel(&input, kernel);
1422        assert!(res.is_err());
1423        Ok(())
1424    }
1425
1426    fn check_donchian_very_small_dataset(
1427        test_name: &str,
1428        kernel: Kernel,
1429    ) -> Result<(), Box<dyn std::error::Error>> {
1430        skip_if_unsupported!(kernel, test_name);
1431        let high = [100.0];
1432        let low = [90.0];
1433        let params = DonchianParams { period: Some(20) };
1434        let input = DonchianInput::from_slices(&high, &low, params);
1435        let res = donchian_with_kernel(&input, kernel);
1436        assert!(res.is_err());
1437        Ok(())
1438    }
1439
1440    fn check_donchian_mismatched_length(
1441        test_name: &str,
1442        kernel: Kernel,
1443    ) -> Result<(), Box<dyn std::error::Error>> {
1444        skip_if_unsupported!(kernel, test_name);
1445        let high = [10.0, 20.0, 30.0];
1446        let low = [5.0, 3.0];
1447        let params = DonchianParams { period: Some(2) };
1448        let input = DonchianInput::from_slices(&high, &low, params);
1449        let res = donchian_with_kernel(&input, kernel);
1450        assert!(res.is_err());
1451        Ok(())
1452    }
1453
1454    fn check_donchian_all_nan_data(
1455        test_name: &str,
1456        kernel: Kernel,
1457    ) -> Result<(), Box<dyn std::error::Error>> {
1458        skip_if_unsupported!(kernel, test_name);
1459        let high = [f64::NAN, f64::NAN];
1460        let low = [f64::NAN, f64::NAN];
1461        let params = DonchianParams { period: Some(2) };
1462        let input = DonchianInput::from_slices(&high, &low, params);
1463        let res = donchian_with_kernel(&input, kernel);
1464        assert!(res.is_err());
1465        Ok(())
1466    }
1467
1468    fn check_donchian_partial_computation(
1469        test_name: &str,
1470        kernel: Kernel,
1471    ) -> Result<(), Box<dyn std::error::Error>> {
1472        skip_if_unsupported!(kernel, test_name);
1473        let high = [f64::NAN, 3.0, 5.0, 8.0, 8.5, 9.0, 2.0, 1.0];
1474        let low = [f64::NAN, 2.0, 1.0, 4.0, 4.5, 1.0, 1.0, 0.5];
1475        let params = DonchianParams { period: Some(3) };
1476        let input = DonchianInput::from_slices(&high, &low, params);
1477        let output = donchian_with_kernel(&input, kernel)?;
1478        assert_eq!(output.upperband.len(), high.len());
1479        assert!(output.upperband[2].is_nan());
1480        assert!(!output.upperband[3].is_nan());
1481        Ok(())
1482    }
1483
1484    #[cfg(debug_assertions)]
1485    fn check_donchian_no_poison(
1486        test_name: &str,
1487        kernel: Kernel,
1488    ) -> Result<(), Box<dyn std::error::Error>> {
1489        skip_if_unsupported!(kernel, test_name);
1490
1491        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1492        let candles = read_candles_from_csv(file_path)?;
1493
1494        let test_params = vec![
1495            DonchianParams::default(),
1496            DonchianParams { period: Some(2) },
1497            DonchianParams { period: Some(5) },
1498            DonchianParams { period: Some(10) },
1499            DonchianParams { period: Some(20) },
1500            DonchianParams { period: Some(50) },
1501            DonchianParams { period: Some(100) },
1502            DonchianParams { period: Some(200) },
1503            DonchianParams { period: Some(500) },
1504            DonchianParams { period: Some(14) },
1505            DonchianParams { period: Some(26) },
1506        ];
1507
1508        for (param_idx, params) in test_params.iter().enumerate() {
1509            let input = DonchianInput::from_candles(&candles, params.clone());
1510            let output = donchian_with_kernel(&input, kernel)?;
1511
1512            let bands = [
1513                ("upperband", &output.upperband),
1514                ("middleband", &output.middleband),
1515                ("lowerband", &output.lowerband),
1516            ];
1517
1518            for (band_name, band_values) in &bands {
1519                for (i, &val) in band_values.iter().enumerate() {
1520                    if val.is_nan() {
1521                        continue;
1522                    }
1523
1524                    let bits = val.to_bits();
1525
1526                    if bits == 0x11111111_11111111 {
1527                        panic!(
1528							"[{}] Found alloc_with_nan_prefix poison value {} (0x{:016X}) at index {} \
1529							 in {} with params: period={} (param set {})",
1530							test_name, val, bits, i, band_name,
1531							params.period.unwrap_or(20), param_idx
1532						);
1533                    }
1534
1535                    if bits == 0x22222222_22222222 {
1536                        panic!(
1537							"[{}] Found init_matrix_prefixes poison value {} (0x{:016X}) at index {} \
1538							 in {} with params: period={} (param set {})",
1539							test_name, val, bits, i, band_name,
1540							params.period.unwrap_or(20), param_idx
1541						);
1542                    }
1543
1544                    if bits == 0x33333333_33333333 {
1545                        panic!(
1546							"[{}] Found make_uninit_matrix poison value {} (0x{:016X}) at index {} \
1547							 in {} with params: period={} (param set {})",
1548							test_name, val, bits, i, band_name,
1549							params.period.unwrap_or(20), param_idx
1550						);
1551                    }
1552                }
1553            }
1554        }
1555
1556        Ok(())
1557    }
1558
1559    #[cfg(not(debug_assertions))]
1560    fn check_donchian_no_poison(
1561        _test_name: &str,
1562        _kernel: Kernel,
1563    ) -> Result<(), Box<dyn std::error::Error>> {
1564        Ok(())
1565    }
1566
1567    macro_rules! generate_all_donchian_tests {
1568        ($($test_fn:ident),*) => {
1569            paste::paste! {
1570                $(
1571                    #[test]
1572                    fn [<$test_fn _scalar_f64>]() {
1573                        let _ = $test_fn(stringify!([<$test_fn _scalar_f64>]), Kernel::Scalar);
1574                    }
1575                )*
1576                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1577                $(
1578                    #[test]
1579                    fn [<$test_fn _avx2_f64>]() {
1580                        let _ = $test_fn(stringify!([<$test_fn _avx2_f64>]), Kernel::Avx2);
1581                    }
1582                    #[test]
1583                    fn [<$test_fn _avx512_f64>]() {
1584                        let _ = $test_fn(stringify!([<$test_fn _avx512_f64>]), Kernel::Avx512);
1585                    }
1586                )*
1587            }
1588        }
1589    }
1590
1591    #[test]
1592    fn test_donchian_into_matches_api() {
1593        let n = 256usize;
1594        let mut high = vec![f64::NAN; n];
1595        let mut low = vec![f64::NAN; n];
1596
1597        for i in 5..n {
1598            let base = (i as f64).sin() * 10.0 + 100.0;
1599            high[i] = base + 2.0 + ((i % 7) as f64) * 0.1;
1600            low[i] = base - 2.0 - ((i % 5) as f64) * 0.1;
1601        }
1602        for idx in [37usize, 88, 133, 210] {
1603            high[idx] = f64::NAN;
1604        }
1605        for idx in [59usize, 120, 178, 220] {
1606            low[idx] = f64::NAN;
1607        }
1608
1609        let input = DonchianInput::from_slices(&high, &low, DonchianParams::default());
1610        let expected = donchian(&input).expect("baseline donchian() failed");
1611
1612        let mut up = vec![0.0; n];
1613        let mut mid = vec![0.0; n];
1614        let mut lo = vec![0.0; n];
1615
1616        #[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
1617        {
1618            donchian_into(&input, &mut up, &mut mid, &mut lo).expect("donchian_into failed");
1619        }
1620
1621        assert_eq!(expected.upperband.len(), up.len());
1622        assert_eq!(expected.middleband.len(), mid.len());
1623        assert_eq!(expected.lowerband.len(), lo.len());
1624
1625        let eq = |a: f64, b: f64| (a.is_nan() && b.is_nan()) || (a == b);
1626        for i in 0..n {
1627            assert!(eq(expected.upperband[i], up[i]), "upper mismatch at {}", i);
1628            assert!(
1629                eq(expected.middleband[i], mid[i]),
1630                "middle mismatch at {}",
1631                i
1632            );
1633            assert!(eq(expected.lowerband[i], lo[i]), "lower mismatch at {}", i);
1634        }
1635    }
1636
1637    #[cfg(feature = "proptest")]
1638    #[allow(clippy::float_cmp)]
1639    fn check_donchian_property(
1640        test_name: &str,
1641        kernel: Kernel,
1642    ) -> Result<(), Box<dyn std::error::Error>> {
1643        use proptest::prelude::*;
1644        skip_if_unsupported!(kernel, test_name);
1645
1646        let random_strat = (2usize..=64)
1647            .prop_flat_map(|period| {
1648                (
1649                    prop::collection::vec((50f64..5000f64, 0.1f64..50f64), period..400),
1650                    Just(period),
1651                )
1652            })
1653            .prop_map(|(price_pairs, period)| {
1654                let mut high = Vec::with_capacity(price_pairs.len());
1655                let mut low = Vec::with_capacity(price_pairs.len());
1656                for (base, spread) in price_pairs {
1657                    low.push(base);
1658                    high.push(base + spread);
1659                }
1660                (high, low, period)
1661            });
1662
1663        let constant_strat =
1664            (2usize..=64, 50f64..5000f64, 0f64..50f64).prop_map(|(period, base_price, spread)| {
1665                let len = period + 50;
1666                let high = vec![base_price + spread; len];
1667                let low = vec![base_price; len];
1668                (high, low, period)
1669            });
1670
1671        let trending_strat = (2usize..=64).prop_map(|period| {
1672            let len = period + 100;
1673            let mut high = Vec::with_capacity(len);
1674            let mut low = Vec::with_capacity(len);
1675            for i in 0..len {
1676                let base = 100.0 + i as f64 * 10.0;
1677                low.push(base);
1678                high.push(base + 5.0);
1679            }
1680            (high, low, period)
1681        });
1682
1683        let volatile_strat = (2usize..=64)
1684            .prop_flat_map(|period| {
1685                (
1686                    prop::collection::vec((10f64..10000f64, 0.1f64..500f64), period..200),
1687                    Just(period),
1688                )
1689            })
1690            .prop_map(|(price_pairs, period)| {
1691                let mut high = Vec::with_capacity(price_pairs.len());
1692                let mut low = Vec::with_capacity(price_pairs.len());
1693                for (i, (base, spread)) in price_pairs.iter().enumerate() {
1694                    let volatility = if i % 3 == 0 { 2.0 } else { 0.5 };
1695                    low.push(base - spread * 0.1);
1696                    high.push(base + spread * volatility);
1697                }
1698                (high, low, period)
1699            });
1700
1701        let single_price_strat = (2usize..=64, 50f64..5000f64).prop_map(|(period, price)| {
1702            let len = period + 50;
1703            let high = vec![price; len];
1704            let low = vec![price; len];
1705            (high, low, period)
1706        });
1707
1708        let combined_strat = prop_oneof![
1709            random_strat,
1710            constant_strat,
1711            trending_strat,
1712            volatile_strat,
1713            single_price_strat,
1714        ];
1715
1716        proptest::test_runner::TestRunner::default()
1717            .run(&combined_strat, |(high, low, period)| {
1718                for i in 0..high.len() {
1719                    prop_assert!(
1720                        high[i] >= low[i],
1721                        "Invalid input data at index {}: high ({}) < low ({})",
1722                        i,
1723                        high[i],
1724                        low[i]
1725                    );
1726                }
1727
1728                let params = DonchianParams {
1729                    period: Some(period),
1730                };
1731                let input = DonchianInput::from_slices(&high, &low, params.clone());
1732
1733                let output = donchian_with_kernel(&input, kernel).unwrap();
1734                let ref_output = donchian_with_kernel(&input, Kernel::Scalar).unwrap();
1735
1736                for i in 0..(period - 1) {
1737                    prop_assert!(
1738                        output.upperband[i].is_nan(),
1739                        "Expected NaN during warmup at index {}, got {} (period={})",
1740                        i,
1741                        output.upperband[i],
1742                        period
1743                    );
1744                    prop_assert!(
1745                        output.middleband[i].is_nan(),
1746                        "Expected NaN during warmup at index {}, got {} (period={})",
1747                        i,
1748                        output.middleband[i],
1749                        period
1750                    );
1751                    prop_assert!(
1752                        output.lowerband[i].is_nan(),
1753                        "Expected NaN during warmup at index {}, got {} (period={})",
1754                        i,
1755                        output.lowerband[i],
1756                        period
1757                    );
1758                }
1759
1760                for i in (period - 1)..high.len() {
1761                    let start = i + 1 - period;
1762                    let window_high = &high[start..=i];
1763                    let window_low = &low[start..=i];
1764
1765                    let expected_max = window_high
1766                        .iter()
1767                        .cloned()
1768                        .fold(f64::NEG_INFINITY, f64::max);
1769                    let expected_min = window_low.iter().cloned().fold(f64::INFINITY, f64::min);
1770                    let expected_mid = 0.5 * (expected_max + expected_min);
1771
1772                    let upper = output.upperband[i];
1773                    let middle = output.middleband[i];
1774                    let lower = output.lowerband[i];
1775
1776                    prop_assert!(
1777                        (upper - expected_max).abs() < 1e-9,
1778                        "Upperband mismatch at idx {}: got {}, expected {} (period={})",
1779                        i,
1780                        upper,
1781                        expected_max,
1782                        period
1783                    );
1784
1785                    prop_assert!(
1786                        (lower - expected_min).abs() < 1e-9,
1787                        "Lowerband mismatch at idx {}: got {}, expected {} (period={})",
1788                        i,
1789                        lower,
1790                        expected_min,
1791                        period
1792                    );
1793
1794                    prop_assert!(
1795                        (middle - expected_mid).abs() < 1e-9,
1796                        "Middleband mismatch at idx {}: got {}, expected {} (period={})",
1797                        i,
1798                        middle,
1799                        expected_mid,
1800                        period
1801                    );
1802
1803                    prop_assert!(
1804						upper >= middle && middle >= lower,
1805						"Band ordering violated at idx {}: upper={}, middle={}, lower={} (period={})",
1806						i, upper, middle, lower, period
1807					);
1808
1809                    let data_min = window_low.iter().cloned().fold(f64::INFINITY, f64::min);
1810                    let data_max = window_high
1811                        .iter()
1812                        .cloned()
1813                        .fold(f64::NEG_INFINITY, f64::max);
1814                    prop_assert!(
1815						upper <= data_max + 1e-9 && lower >= data_min - 1e-9,
1816						"Bands outside data range at idx {}: upper={}, lower={}, data_range=[{}, {}]",
1817						i, upper, lower, data_min, data_max
1818					);
1819
1820                    if period == 1 {
1821                        prop_assert!(
1822                            (upper - high[i]).abs() < 1e-9,
1823                            "Period=1: upper should equal current high at idx {}: {} vs {}",
1824                            i,
1825                            upper,
1826                            high[i]
1827                        );
1828                        prop_assert!(
1829                            (lower - low[i]).abs() < 1e-9,
1830                            "Period=1: lower should equal current low at idx {}: {} vs {}",
1831                            i,
1832                            lower,
1833                            low[i]
1834                        );
1835                    }
1836
1837                    let window_is_single_price = window_high
1838                        .iter()
1839                        .zip(window_low.iter())
1840                        .all(|(h, l)| (h - l).abs() < f64::EPSILON);
1841
1842                    if window_is_single_price {
1843                        prop_assert!(
1844							(upper - lower).abs() < 1e-9,
1845							"Single price window: bands should converge at idx {}: upper={}, lower={}",
1846							i, upper, lower
1847						);
1848                        prop_assert!(
1849							(middle - upper).abs() < 1e-9,
1850							"Single price window: middle should equal upper/lower at idx {}: middle={}, upper={}",
1851							i, middle, upper
1852						);
1853                    }
1854
1855                    let ref_upper = ref_output.upperband[i];
1856                    let ref_middle = ref_output.middleband[i];
1857                    let ref_lower = ref_output.lowerband[i];
1858
1859                    if !upper.is_finite() || !ref_upper.is_finite() {
1860                        prop_assert!(
1861                            upper.to_bits() == ref_upper.to_bits(),
1862                            "Upper finite/NaN mismatch at idx {}: {} vs {}",
1863                            i,
1864                            upper,
1865                            ref_upper
1866                        );
1867                    } else {
1868                        let ulp_diff = upper.to_bits().abs_diff(ref_upper.to_bits());
1869                        prop_assert!(
1870                            (upper - ref_upper).abs() <= 1e-9 || ulp_diff <= 4,
1871                            "Upper kernel mismatch at idx {}: {} vs {} (ULP={})",
1872                            i,
1873                            upper,
1874                            ref_upper,
1875                            ulp_diff
1876                        );
1877                    }
1878
1879                    if !middle.is_finite() || !ref_middle.is_finite() {
1880                        prop_assert!(
1881                            middle.to_bits() == ref_middle.to_bits(),
1882                            "Middle finite/NaN mismatch at idx {}: {} vs {}",
1883                            i,
1884                            middle,
1885                            ref_middle
1886                        );
1887                    } else {
1888                        let ulp_diff = middle.to_bits().abs_diff(ref_middle.to_bits());
1889                        prop_assert!(
1890                            (middle - ref_middle).abs() <= 1e-9 || ulp_diff <= 4,
1891                            "Middle kernel mismatch at idx {}: {} vs {} (ULP={})",
1892                            i,
1893                            middle,
1894                            ref_middle,
1895                            ulp_diff
1896                        );
1897                    }
1898
1899                    if !lower.is_finite() || !ref_lower.is_finite() {
1900                        prop_assert!(
1901                            lower.to_bits() == ref_lower.to_bits(),
1902                            "Lower finite/NaN mismatch at idx {}: {} vs {}",
1903                            i,
1904                            lower,
1905                            ref_lower
1906                        );
1907                    } else {
1908                        let ulp_diff = lower.to_bits().abs_diff(ref_lower.to_bits());
1909                        prop_assert!(
1910                            (lower - ref_lower).abs() <= 1e-9 || ulp_diff <= 4,
1911                            "Lower kernel mismatch at idx {}: {} vs {} (ULP={})",
1912                            i,
1913                            lower,
1914                            ref_lower,
1915                            ulp_diff
1916                        );
1917                    }
1918
1919                    for (band_name, val) in [("upper", upper), ("middle", middle), ("lower", lower)]
1920                    {
1921                        let bits = val.to_bits();
1922                        prop_assert!(
1923                            bits != 0x11111111_11111111,
1924                            "Found alloc_with_nan_prefix poison in {} at idx {}: {} (0x{:016X})",
1925                            band_name,
1926                            i,
1927                            val,
1928                            bits
1929                        );
1930                        prop_assert!(
1931                            bits != 0x22222222_22222222,
1932                            "Found init_matrix_prefixes poison in {} at idx {}: {} (0x{:016X})",
1933                            band_name,
1934                            i,
1935                            val,
1936                            bits
1937                        );
1938                        prop_assert!(
1939                            bits != 0x33333333_33333333,
1940                            "Found make_uninit_matrix poison in {} at idx {}: {} (0x{:016X})",
1941                            band_name,
1942                            i,
1943                            val,
1944                            bits
1945                        );
1946                    }
1947                }
1948
1949                Ok(())
1950            })
1951            .unwrap();
1952
1953        Ok(())
1954    }
1955
1956    generate_all_donchian_tests!(
1957        check_donchian_partial_params,
1958        check_donchian_accuracy,
1959        check_donchian_zero_period,
1960        check_donchian_period_exceeds_length,
1961        check_donchian_very_small_dataset,
1962        check_donchian_mismatched_length,
1963        check_donchian_all_nan_data,
1964        check_donchian_partial_computation,
1965        check_donchian_no_poison
1966    );
1967
1968    #[cfg(feature = "proptest")]
1969    generate_all_donchian_tests!(check_donchian_property);
1970
1971    fn check_batch_default_row(
1972        test: &str,
1973        kernel: Kernel,
1974    ) -> Result<(), Box<dyn std::error::Error>> {
1975        skip_if_unsupported!(kernel, test);
1976        let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1977        let c = read_candles_from_csv(file)?;
1978        let output = DonchianBatchBuilder::new()
1979            .kernel(kernel)
1980            .apply_candles(&c)?;
1981        let def = DonchianParams::default();
1982        let row = output.upper_for(&def).expect("default row missing");
1983        assert_eq!(row.len(), c.close.len());
1984        Ok(())
1985    }
1986
1987    macro_rules! gen_batch_tests {
1988        ($fn_name:ident) => {
1989            paste::paste! {
1990                #[test] fn [<$fn_name _scalar>]()      {
1991                    let _ = $fn_name(stringify!([<$fn_name _scalar>]), Kernel::ScalarBatch);
1992                }
1993                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1994                #[test] fn [<$fn_name _avx2>]()        {
1995                    let _ = $fn_name(stringify!([<$fn_name _avx2>]), Kernel::Avx2Batch);
1996                }
1997                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1998                #[test] fn [<$fn_name _avx512>]()      {
1999                    let _ = $fn_name(stringify!([<$fn_name _avx512>]), Kernel::Avx512Batch);
2000                }
2001                #[test] fn [<$fn_name _auto_detect>]() {
2002                    let _ = $fn_name(stringify!([<$fn_name _auto_detect>]), Kernel::Auto);
2003                }
2004            }
2005        };
2006    }
2007    gen_batch_tests!(check_batch_default_row);
2008    gen_batch_tests!(check_batch_no_poison);
2009
2010    #[cfg(debug_assertions)]
2011    fn check_batch_no_poison(test: &str, kernel: Kernel) -> Result<(), Box<dyn std::error::Error>> {
2012        skip_if_unsupported!(kernel, test);
2013
2014        let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
2015        let c = read_candles_from_csv(file)?;
2016
2017        let test_configs = vec![
2018            (2, 10, 2),
2019            (10, 50, 10),
2020            (20, 100, 20),
2021            (50, 150, 25),
2022            (2, 5, 1),
2023            (100, 300, 50),
2024            (14, 26, 4),
2025            (5, 20, 3),
2026        ];
2027
2028        for (cfg_idx, &(p_start, p_end, p_step)) in test_configs.iter().enumerate() {
2029            let output = DonchianBatchBuilder::new()
2030                .kernel(kernel)
2031                .period_range(p_start, p_end, p_step)
2032                .apply_candles(&c)?;
2033
2034            let bands = [
2035                ("upper", &output.upper),
2036                ("middle", &output.middle),
2037                ("lower", &output.lower),
2038            ];
2039
2040            for (band_name, band_values) in &bands {
2041                for (idx, &val) in band_values.iter().enumerate() {
2042                    if val.is_nan() {
2043                        continue;
2044                    }
2045
2046                    let bits = val.to_bits();
2047                    let row = idx / output.cols;
2048                    let col = idx % output.cols;
2049                    let combo = &output.combos[row];
2050
2051                    if bits == 0x11111111_11111111 {
2052                        panic!(
2053							"[{}] Config {}: Found alloc_with_nan_prefix poison value {} (0x{:016X}) \
2054							 in {} at row {} col {} (flat index {}) with params: period={}",
2055							test, cfg_idx, val, bits, band_name, row, col, idx,
2056							combo.period.unwrap_or(20)
2057						);
2058                    }
2059
2060                    if bits == 0x22222222_22222222 {
2061                        panic!(
2062							"[{}] Config {}: Found init_matrix_prefixes poison value {} (0x{:016X}) \
2063							 in {} at row {} col {} (flat index {}) with params: period={}",
2064							test, cfg_idx, val, bits, band_name, row, col, idx,
2065							combo.period.unwrap_or(20)
2066						);
2067                    }
2068
2069                    if bits == 0x33333333_33333333 {
2070                        panic!(
2071                            "[{}] Config {}: Found make_uninit_matrix poison value {} (0x{:016X}) \
2072							 in {} at row {} col {} (flat index {}) with params: period={}",
2073                            test,
2074                            cfg_idx,
2075                            val,
2076                            bits,
2077                            band_name,
2078                            row,
2079                            col,
2080                            idx,
2081                            combo.period.unwrap_or(20)
2082                        );
2083                    }
2084                }
2085            }
2086        }
2087
2088        Ok(())
2089    }
2090
2091    #[cfg(not(debug_assertions))]
2092    fn check_batch_no_poison(
2093        _test: &str,
2094        _kernel: Kernel,
2095    ) -> Result<(), Box<dyn std::error::Error>> {
2096        Ok(())
2097    }
2098}
2099
2100#[cfg(feature = "python")]
2101use crate::utilities::kernel_validation::validate_kernel;
2102#[cfg(all(feature = "python", feature = "cuda"))]
2103use numpy::PyUntypedArrayMethods;
2104#[cfg(feature = "python")]
2105use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
2106#[cfg(feature = "python")]
2107use pyo3::exceptions::PyValueError;
2108#[cfg(feature = "python")]
2109use pyo3::prelude::*;
2110#[cfg(feature = "python")]
2111use pyo3::types::PyDict;
2112
2113#[cfg(feature = "python")]
2114#[pyfunction(name = "donchian")]
2115#[pyo3(signature = (high, low, period, kernel=None))]
2116pub fn donchian_py<'py>(
2117    py: Python<'py>,
2118    high: PyReadonlyArray1<'py, f64>,
2119    low: PyReadonlyArray1<'py, f64>,
2120    period: usize,
2121    kernel: Option<&str>,
2122) -> PyResult<(
2123    Bound<'py, PyArray1<f64>>,
2124    Bound<'py, PyArray1<f64>>,
2125    Bound<'py, PyArray1<f64>>,
2126)> {
2127    use numpy::{IntoPyArray, PyArrayMethods};
2128
2129    let high_slice = high.as_slice()?;
2130    let low_slice = low.as_slice()?;
2131    let kern = validate_kernel(kernel, false)?;
2132
2133    let params = DonchianParams {
2134        period: Some(period),
2135    };
2136    let input = DonchianInput::from_slices(high_slice, low_slice, params);
2137
2138    let (upper_vec, middle_vec, lower_vec) = py
2139        .allow_threads(|| {
2140            donchian_with_kernel(&input, kern).map(|o| (o.upperband, o.middleband, o.lowerband))
2141        })
2142        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2143
2144    Ok((
2145        upper_vec.into_pyarray(py),
2146        middle_vec.into_pyarray(py),
2147        lower_vec.into_pyarray(py),
2148    ))
2149}
2150
2151#[cfg(feature = "python")]
2152#[pyclass(name = "DonchianStream")]
2153pub struct DonchianStreamPy {
2154    stream: DonchianStream,
2155}
2156
2157#[cfg(feature = "python")]
2158#[pymethods]
2159impl DonchianStreamPy {
2160    #[new]
2161    fn new(period: usize) -> PyResult<Self> {
2162        let params = DonchianParams {
2163            period: Some(period),
2164        };
2165        let stream =
2166            DonchianStream::try_new(params).map_err(|e| PyValueError::new_err(e.to_string()))?;
2167        Ok(DonchianStreamPy { stream })
2168    }
2169
2170    fn update(&mut self, high: f64, low: f64) -> Option<(f64, f64, f64)> {
2171        self.stream.update(high, low)
2172    }
2173}
2174
2175#[inline(always)]
2176fn donchian_batch_inner_into(
2177    high: &[f64],
2178    low: &[f64],
2179    sweep: &DonchianBatchRange,
2180    kern: Kernel,
2181    parallel: bool,
2182    out_upper: &mut [f64],
2183    out_middle: &mut [f64],
2184    out_lower: &mut [f64],
2185) -> Result<Vec<DonchianParams>, DonchianError> {
2186    let combos = expand_grid(sweep)?;
2187    if combos.is_empty() {
2188        return Err(DonchianError::InvalidRange {
2189            start: sweep.period.0,
2190            end: sweep.period.1,
2191            step: sweep.period.2,
2192        });
2193    }
2194    if high.len() != low.len() {
2195        return Err(DonchianError::MismatchedLength);
2196    }
2197
2198    let first = high
2199        .iter()
2200        .position(|x| !x.is_nan())
2201        .zip(low.iter().position(|x| !x.is_nan()))
2202        .map(|(a, b)| a.max(b));
2203    let first = match first {
2204        Some(idx) => idx,
2205        None => return Err(DonchianError::AllValuesNaN),
2206    };
2207
2208    let max_p = combos.iter().map(|c| c.period.unwrap()).max().unwrap();
2209    if high.len() - first < max_p {
2210        return Err(DonchianError::NotEnoughValidData {
2211            needed: max_p,
2212            valid: high.len() - first,
2213        });
2214    }
2215
2216    let rows = combos.len();
2217    let cols = high.len();
2218
2219    for (row, combo) in combos.iter().enumerate() {
2220        let period = combo.period.unwrap();
2221        let warmup = first + period - 1;
2222        let row_start = row * cols;
2223        for i in 0..warmup {
2224            out_upper[row_start + i] = f64::NAN;
2225            out_middle[row_start + i] = f64::NAN;
2226            out_lower[row_start + i] = f64::NAN;
2227        }
2228    }
2229
2230    let do_row =
2231        |row: usize, out_upper: &mut [f64], out_middle: &mut [f64], out_lower: &mut [f64]| unsafe {
2232            let period = combos[row].period.unwrap();
2233            match kern {
2234                Kernel::Scalar => {
2235                    donchian_row_scalar(high, low, first, period, out_upper, out_middle, out_lower)
2236                }
2237                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
2238                Kernel::Avx2 => {
2239                    donchian_row_avx2(high, low, first, period, out_upper, out_middle, out_lower)
2240                }
2241                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
2242                Kernel::Avx512 => {
2243                    donchian_row_avx512(high, low, first, period, out_upper, out_middle, out_lower)
2244                }
2245                _ => unreachable!(),
2246            }
2247        };
2248
2249    if parallel {
2250        #[cfg(not(target_arch = "wasm32"))]
2251        {
2252            out_upper
2253                .par_chunks_mut(cols)
2254                .zip(out_middle.par_chunks_mut(cols))
2255                .zip(out_lower.par_chunks_mut(cols))
2256                .enumerate()
2257                .for_each(|(row, ((upper, middle), lower))| do_row(row, upper, middle, lower));
2258        }
2259
2260        #[cfg(target_arch = "wasm32")]
2261        {
2262            for (((upper, middle), lower), row) in out_upper
2263                .chunks_mut(cols)
2264                .zip(out_middle.chunks_mut(cols))
2265                .zip(out_lower.chunks_mut(cols))
2266                .zip(0..)
2267            {
2268                do_row(row, upper, middle, lower);
2269            }
2270        }
2271    } else {
2272        for (((upper, middle), lower), row) in out_upper
2273            .chunks_mut(cols)
2274            .zip(out_middle.chunks_mut(cols))
2275            .zip(out_lower.chunks_mut(cols))
2276            .zip(0..)
2277        {
2278            do_row(row, upper, middle, lower);
2279        }
2280    }
2281
2282    Ok(combos)
2283}
2284
2285#[cfg(feature = "python")]
2286#[pyfunction(name = "donchian_batch")]
2287#[pyo3(signature = (high, low, period_range, kernel=None))]
2288pub fn donchian_batch_py<'py>(
2289    py: Python<'py>,
2290    high: PyReadonlyArray1<'py, f64>,
2291    low: PyReadonlyArray1<'py, f64>,
2292    period_range: (usize, usize, usize),
2293    kernel: Option<&str>,
2294) -> PyResult<Bound<'py, PyDict>> {
2295    use numpy::{IntoPyArray, PyArray1, PyArrayMethods};
2296    use pyo3::types::PyDict;
2297
2298    let high_slice = high.as_slice()?;
2299    let low_slice = low.as_slice()?;
2300    let kern = validate_kernel(kernel, true)?;
2301
2302    let sweep = DonchianBatchRange {
2303        period: period_range,
2304    };
2305
2306    let combos = expand_grid(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
2307    let rows = combos.len();
2308    let cols = high_slice.len();
2309
2310    let upper_arr = unsafe { PyArray1::<f64>::new(py, [rows * cols], false) };
2311    let middle_arr = unsafe { PyArray1::<f64>::new(py, [rows * cols], false) };
2312    let lower_arr = unsafe { PyArray1::<f64>::new(py, [rows * cols], false) };
2313
2314    let upper_slice = unsafe { upper_arr.as_slice_mut()? };
2315    let middle_slice = unsafe { middle_arr.as_slice_mut()? };
2316    let lower_slice = unsafe { lower_arr.as_slice_mut()? };
2317
2318    let combos = py
2319        .allow_threads(|| {
2320            let kernel = match kern {
2321                Kernel::Auto => detect_best_batch_kernel(),
2322                k => k,
2323            };
2324
2325            let simd = match kernel {
2326                Kernel::Avx512Batch => Kernel::Avx512,
2327                Kernel::Avx2Batch => Kernel::Avx2,
2328                Kernel::ScalarBatch => Kernel::Scalar,
2329                _ => kernel,
2330            };
2331
2332            donchian_batch_inner_into(
2333                high_slice,
2334                low_slice,
2335                &sweep,
2336                simd,
2337                true,
2338                upper_slice,
2339                middle_slice,
2340                lower_slice,
2341            )
2342        })
2343        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2344
2345    let dict = PyDict::new(py);
2346    dict.set_item("upper", upper_arr.reshape((rows, cols))?)?;
2347    dict.set_item("middle", middle_arr.reshape((rows, cols))?)?;
2348    dict.set_item("lower", lower_arr.reshape((rows, cols))?)?;
2349    dict.set_item(
2350        "periods",
2351        combos
2352            .iter()
2353            .map(|p| p.period.unwrap() as u64)
2354            .collect::<Vec<_>>()
2355            .into_pyarray(py),
2356    )?;
2357
2358    Ok(dict)
2359}
2360
2361#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2362#[wasm_bindgen]
2363pub struct DonchianResult {
2364    values: Vec<f64>,
2365    rows: usize,
2366    cols: usize,
2367}
2368
2369#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2370#[wasm_bindgen]
2371impl DonchianResult {
2372    #[wasm_bindgen(getter)]
2373    pub fn values(&self) -> Vec<f64> {
2374        self.values.clone()
2375    }
2376
2377    #[wasm_bindgen(getter)]
2378    pub fn rows(&self) -> usize {
2379        self.rows
2380    }
2381
2382    #[wasm_bindgen(getter)]
2383    pub fn cols(&self) -> usize {
2384        self.cols
2385    }
2386}
2387
2388#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2389#[wasm_bindgen]
2390pub fn donchian_js(high: &[f64], low: &[f64], period: usize) -> Result<DonchianResult, JsValue> {
2391    let params = DonchianParams {
2392        period: Some(period),
2393    };
2394    let input = DonchianInput::from_slices(high, low, params);
2395
2396    let len = high.len();
2397    let mut upper = vec![0.0; len];
2398    let mut middle = vec![0.0; len];
2399    let mut lower = vec![0.0; len];
2400
2401    donchian_into_slice(&mut upper, &mut middle, &mut lower, &input, Kernel::Auto)
2402        .map_err(|e| JsValue::from_str(&e.to_string()))?;
2403
2404    let mut values = Vec::with_capacity(len * 3);
2405    values.extend_from_slice(&upper);
2406    values.extend_from_slice(&middle);
2407    values.extend_from_slice(&lower);
2408
2409    Ok(DonchianResult {
2410        values,
2411        rows: 3,
2412        cols: len,
2413    })
2414}
2415
2416#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2417#[wasm_bindgen]
2418pub fn donchian_into(
2419    high_ptr: *const f64,
2420    low_ptr: *const f64,
2421    upper_ptr: *mut f64,
2422    middle_ptr: *mut f64,
2423    lower_ptr: *mut f64,
2424    len: usize,
2425    period: usize,
2426) -> Result<(), JsValue> {
2427    if high_ptr.is_null()
2428        || low_ptr.is_null()
2429        || upper_ptr.is_null()
2430        || middle_ptr.is_null()
2431        || lower_ptr.is_null()
2432    {
2433        return Err(JsValue::from_str("Null pointer provided"));
2434    }
2435
2436    unsafe {
2437        let high = std::slice::from_raw_parts(high_ptr, len);
2438        let low = std::slice::from_raw_parts(low_ptr, len);
2439        let params = DonchianParams {
2440            period: Some(period),
2441        };
2442        let input = DonchianInput::from_slices(high, low, params);
2443
2444        let need_temp = high_ptr == upper_ptr as *const f64
2445            || high_ptr == middle_ptr as *const f64
2446            || high_ptr == lower_ptr as *const f64
2447            || low_ptr == upper_ptr as *const f64
2448            || low_ptr == middle_ptr as *const f64
2449            || low_ptr == lower_ptr as *const f64
2450            || upper_ptr == middle_ptr
2451            || upper_ptr == lower_ptr
2452            || middle_ptr == lower_ptr;
2453
2454        if need_temp {
2455            let mut temp_upper = vec![0.0; len];
2456            let mut temp_middle = vec![0.0; len];
2457            let mut temp_lower = vec![0.0; len];
2458
2459            donchian_into_slice(
2460                &mut temp_upper,
2461                &mut temp_middle,
2462                &mut temp_lower,
2463                &input,
2464                Kernel::Auto,
2465            )
2466            .map_err(|e| JsValue::from_str(&e.to_string()))?;
2467
2468            let upper_out = std::slice::from_raw_parts_mut(upper_ptr, len);
2469            let middle_out = std::slice::from_raw_parts_mut(middle_ptr, len);
2470            let lower_out = std::slice::from_raw_parts_mut(lower_ptr, len);
2471
2472            upper_out.copy_from_slice(&temp_upper);
2473            middle_out.copy_from_slice(&temp_middle);
2474            lower_out.copy_from_slice(&temp_lower);
2475        } else {
2476            let upper_out = std::slice::from_raw_parts_mut(upper_ptr, len);
2477            let middle_out = std::slice::from_raw_parts_mut(middle_ptr, len);
2478            let lower_out = std::slice::from_raw_parts_mut(lower_ptr, len);
2479
2480            donchian_into_slice(upper_out, middle_out, lower_out, &input, Kernel::Auto)
2481                .map_err(|e| JsValue::from_str(&e.to_string()))?;
2482        }
2483
2484        Ok(())
2485    }
2486}
2487
2488#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2489#[wasm_bindgen]
2490pub fn donchian_alloc(len: usize) -> *mut f64 {
2491    let mut vec = Vec::<f64>::with_capacity(len);
2492    let ptr = vec.as_mut_ptr();
2493    std::mem::forget(vec);
2494    ptr
2495}
2496
2497#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2498#[wasm_bindgen]
2499pub fn donchian_free(ptr: *mut f64, len: usize) {
2500    if !ptr.is_null() {
2501        unsafe {
2502            let _ = Vec::from_raw_parts(ptr, len, len);
2503        }
2504    }
2505}
2506
2507#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2508#[derive(Serialize, Deserialize)]
2509pub struct DonchianBatchConfig {
2510    pub period_range: (usize, usize, usize),
2511}
2512
2513#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2514#[derive(Serialize, Deserialize)]
2515pub struct DonchianBatchJsOutput {
2516    pub upper: Vec<f64>,
2517    pub middle: Vec<f64>,
2518    pub lower: Vec<f64>,
2519    pub periods: Vec<usize>,
2520    pub rows: usize,
2521    pub cols: usize,
2522}
2523
2524#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2525#[wasm_bindgen(js_name = donchian_batch)]
2526pub fn donchian_batch_js(high: &[f64], low: &[f64], config: JsValue) -> Result<JsValue, JsValue> {
2527    let config: DonchianBatchConfig = serde_wasm_bindgen::from_value(config)
2528        .map_err(|e| JsValue::from_str(&format!("Invalid config: {}", e)))?;
2529
2530    let sweep = DonchianBatchRange {
2531        period: config.period_range,
2532    };
2533
2534    let combos = expand_grid(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
2535    let rows = combos.len();
2536    let cols = high.len();
2537
2538    let mut upper = vec![0.0; rows * cols];
2539    let mut middle = vec![0.0; rows * cols];
2540    let mut lower = vec![0.0; rows * cols];
2541
2542    donchian_batch_inner_into(
2543        high,
2544        low,
2545        &sweep,
2546        detect_best_kernel(),
2547        false,
2548        &mut upper,
2549        &mut middle,
2550        &mut lower,
2551    )
2552    .map_err(|e| JsValue::from_str(&e.to_string()))?;
2553
2554    let js_output = DonchianBatchJsOutput {
2555        upper,
2556        middle,
2557        lower,
2558        periods: combos.iter().map(|p| p.period.unwrap()).collect(),
2559        rows,
2560        cols,
2561    };
2562
2563    serde_wasm_bindgen::to_value(&js_output)
2564        .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
2565}
2566
2567#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2568#[wasm_bindgen]
2569pub fn donchian_batch_into(
2570    high_ptr: *const f64,
2571    low_ptr: *const f64,
2572    upper_ptr: *mut f64,
2573    middle_ptr: *mut f64,
2574    lower_ptr: *mut f64,
2575    len: usize,
2576    period_start: usize,
2577    period_end: usize,
2578    period_step: usize,
2579) -> Result<usize, JsValue> {
2580    if high_ptr.is_null()
2581        || low_ptr.is_null()
2582        || upper_ptr.is_null()
2583        || middle_ptr.is_null()
2584        || lower_ptr.is_null()
2585    {
2586        return Err(JsValue::from_str("Null pointer provided"));
2587    }
2588
2589    unsafe {
2590        let high = std::slice::from_raw_parts(high_ptr, len);
2591        let low = std::slice::from_raw_parts(low_ptr, len);
2592
2593        let sweep = DonchianBatchRange {
2594            period: (period_start, period_end, period_step),
2595        };
2596
2597        let combos = expand_grid(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
2598        let rows = combos.len();
2599        let cols = len;
2600
2601        let upper_out = std::slice::from_raw_parts_mut(upper_ptr, rows * cols);
2602        let middle_out = std::slice::from_raw_parts_mut(middle_ptr, rows * cols);
2603        let lower_out = std::slice::from_raw_parts_mut(lower_ptr, rows * cols);
2604
2605        donchian_batch_inner_into(
2606            high,
2607            low,
2608            &sweep,
2609            detect_best_kernel(),
2610            false,
2611            upper_out,
2612            middle_out,
2613            lower_out,
2614        )
2615        .map_err(|e| JsValue::from_str(&e.to_string()))?;
2616
2617        Ok(rows)
2618    }
2619}
2620
2621#[cfg(all(feature = "python", feature = "cuda"))]
2622use crate::cuda::cuda_available;
2623#[cfg(all(feature = "python", feature = "cuda"))]
2624use crate::cuda::donchian_wrapper::CudaDonchian;
2625#[cfg(all(feature = "python", feature = "cuda"))]
2626use crate::cuda::donchian_wrapper::DeviceArrayF32 as DeviceArrayF32Donch;
2627#[cfg(all(feature = "python", feature = "cuda"))]
2628use crate::utilities::dlpack_cuda::export_f32_cuda_dlpack_2d;
2629
2630#[cfg(all(feature = "python", feature = "cuda"))]
2631#[pyclass(module = "ta_indicators.cuda", unsendable)]
2632pub struct DeviceArrayF32DonchPy {
2633    pub(crate) inner: Option<DeviceArrayF32Donch>,
2634}
2635
2636#[cfg(all(feature = "python", feature = "cuda"))]
2637#[pymethods]
2638impl DeviceArrayF32DonchPy {
2639    #[getter]
2640    fn __cuda_array_interface__<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
2641        let inner = self
2642            .inner
2643            .as_ref()
2644            .ok_or_else(|| PyValueError::new_err("buffer already exported via __dlpack__"))?;
2645        let d = PyDict::new(py);
2646        d.set_item("shape", (inner.rows, inner.cols))?;
2647        d.set_item("typestr", "<f4")?;
2648        let itemsize = std::mem::size_of::<f32>();
2649        let row_stride = inner
2650            .cols
2651            .checked_mul(itemsize)
2652            .ok_or_else(|| PyValueError::new_err("byte stride overflow"))?;
2653        d.set_item("strides", (row_stride, itemsize))?;
2654        d.set_item("data", (inner.device_ptr() as usize, false))?;
2655        d.set_item("version", 3)?;
2656        Ok(d)
2657    }
2658
2659    fn __dlpack_device__(&self) -> PyResult<(i32, i32)> {
2660        let inner = self
2661            .inner
2662            .as_ref()
2663            .ok_or_else(|| PyValueError::new_err("buffer already exported via __dlpack__"))?;
2664        Ok((2, inner.device_id as i32))
2665    }
2666
2667    #[pyo3(signature = (stream=None, max_version=None, dl_device=None, copy=None))]
2668    fn __dlpack__<'py>(
2669        &mut self,
2670        py: Python<'py>,
2671        stream: Option<PyObject>,
2672        max_version: Option<PyObject>,
2673        dl_device: Option<PyObject>,
2674        copy: Option<PyObject>,
2675    ) -> PyResult<PyObject> {
2676        if copy.as_ref().and_then(|c| c.extract::<bool>(py).ok()) == Some(true) {
2677            return Err(PyValueError::new_err(
2678                "copy=True is not supported for donchian CUDA buffers",
2679            ));
2680        }
2681
2682        let (kdl, alloc_dev) = self.__dlpack_device__()?;
2683        if let Some(dev_obj) = dl_device.as_ref() {
2684            if let Ok((dev_ty, dev_id)) = dev_obj.extract::<(i32, i32)>(py) {
2685                if dev_ty != kdl || dev_id != alloc_dev {
2686                    return Err(PyValueError::new_err(
2687                        "dl_device mismatch for donchian CUDA buffer",
2688                    ));
2689                }
2690            }
2691        }
2692        let _ = stream;
2693
2694        let inner = self
2695            .inner
2696            .take()
2697            .ok_or_else(|| PyValueError::new_err("__dlpack__ may only be called once"))?;
2698        let rows = inner.rows;
2699        let cols = inner.cols;
2700        let buf = inner.buf;
2701
2702        let max_version_bound = max_version.map(|obj| obj.into_bound(py));
2703
2704        export_f32_cuda_dlpack_2d(py, buf, rows, cols, alloc_dev, max_version_bound)
2705    }
2706}
2707
2708#[cfg(all(feature = "python", feature = "cuda"))]
2709#[pyfunction(name = "donchian_cuda_batch_dev")]
2710#[pyo3(signature = (high_f32, low_f32, period_range, device_id=0))]
2711pub fn donchian_cuda_batch_dev_py<'py>(
2712    py: Python<'py>,
2713    high_f32: numpy::PyReadonlyArray1<'py, f32>,
2714    low_f32: numpy::PyReadonlyArray1<'py, f32>,
2715    period_range: (usize, usize, usize),
2716    device_id: usize,
2717) -> PyResult<Bound<'py, PyDict>> {
2718    use numpy::IntoPyArray;
2719    if !cuda_available() {
2720        return Err(PyValueError::new_err("CUDA not available"));
2721    }
2722    let h = high_f32.as_slice()?;
2723    let l = low_f32.as_slice()?;
2724    let sweep = DonchianBatchRange {
2725        period: period_range,
2726    };
2727    let (triplet, combos) = py.allow_threads(|| {
2728        let cuda =
2729            CudaDonchian::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
2730        cuda.donchian_batch_dev(h, l, &sweep)
2731            .map_err(|e| PyValueError::new_err(e.to_string()))
2732    })?;
2733
2734    let d = PyDict::new(py);
2735    d.set_item(
2736        "upper",
2737        Py::new(
2738            py,
2739            DeviceArrayF32DonchPy {
2740                inner: Some(triplet.wt1),
2741            },
2742        )?,
2743    )?;
2744    d.set_item(
2745        "middle",
2746        Py::new(
2747            py,
2748            DeviceArrayF32DonchPy {
2749                inner: Some(triplet.wt2),
2750            },
2751        )?,
2752    )?;
2753    d.set_item(
2754        "lower",
2755        Py::new(
2756            py,
2757            DeviceArrayF32DonchPy {
2758                inner: Some(triplet.hist),
2759            },
2760        )?,
2761    )?;
2762    d.set_item(
2763        "periods",
2764        combos
2765            .iter()
2766            .map(|p| p.period.unwrap())
2767            .collect::<Vec<_>>()
2768            .into_pyarray(py),
2769    )?;
2770    d.set_item("rows", combos.len())?;
2771    d.set_item("cols", h.len())?;
2772    Ok(d)
2773}
2774
2775#[cfg(all(feature = "python", feature = "cuda"))]
2776#[pyfunction(name = "donchian_cuda_many_series_one_param_dev")]
2777#[pyo3(signature = (high_tm_f32, low_tm_f32, period, device_id=0))]
2778pub fn donchian_cuda_many_series_one_param_dev_py<'py>(
2779    py: Python<'py>,
2780    high_tm_f32: numpy::PyReadonlyArray2<'py, f32>,
2781    low_tm_f32: numpy::PyReadonlyArray2<'py, f32>,
2782    period: usize,
2783    device_id: usize,
2784) -> PyResult<Bound<'py, PyDict>> {
2785    if !cuda_available() {
2786        return Err(PyValueError::new_err("CUDA not available"));
2787    }
2788    let shape = high_tm_f32.shape();
2789    if shape.len() != 2 || low_tm_f32.shape() != shape {
2790        return Err(PyValueError::new_err(
2791            "expected matching 2D arrays [rows, cols]",
2792        ));
2793    }
2794    let rows = shape[0];
2795    let cols = shape[1];
2796    let high_tm = high_tm_f32.as_slice()?;
2797    let low_tm = low_tm_f32.as_slice()?;
2798    let params = DonchianParams {
2799        period: Some(period),
2800    };
2801    let triplet = py.allow_threads(|| {
2802        let cuda =
2803            CudaDonchian::new(device_id).map_err(|e| PyValueError::new_err(e.to_string()))?;
2804        cuda.donchian_many_series_one_param_time_major_dev(high_tm, low_tm, cols, rows, &params)
2805            .map_err(|e| PyValueError::new_err(e.to_string()))
2806    })?;
2807    let d = PyDict::new(py);
2808    d.set_item(
2809        "upper",
2810        Py::new(
2811            py,
2812            DeviceArrayF32DonchPy {
2813                inner: Some(triplet.wt1),
2814            },
2815        )?,
2816    )?;
2817    d.set_item(
2818        "middle",
2819        Py::new(
2820            py,
2821            DeviceArrayF32DonchPy {
2822                inner: Some(triplet.wt2),
2823            },
2824        )?,
2825    )?;
2826    d.set_item(
2827        "lower",
2828        Py::new(
2829            py,
2830            DeviceArrayF32DonchPy {
2831                inner: Some(triplet.hist),
2832            },
2833        )?,
2834    )?;
2835    d.set_item("rows", rows)?;
2836    d.set_item("cols", cols)?;
2837    d.set_item("period", period)?;
2838    Ok(d)
2839}