Skip to main content

vector_ta/indicators/
percentile_nearest_rank.rs

1#[cfg(feature = "python")]
2use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
3#[cfg(feature = "python")]
4use pyo3::exceptions::PyValueError;
5#[cfg(feature = "python")]
6use pyo3::prelude::*;
7
8#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
9use serde::{Deserialize, Serialize};
10#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
11use wasm_bindgen::prelude::*;
12
13use crate::utilities::data_loader::{source_type, CandleFieldFlags, Candles};
14use crate::utilities::enums::Kernel;
15use crate::utilities::helpers::{
16    alloc_with_nan_prefix, detect_best_batch_kernel, detect_best_kernel, init_matrix_prefixes,
17    make_uninit_matrix,
18};
19#[cfg(feature = "python")]
20use crate::utilities::kernel_validation::validate_kernel;
21
22use std::convert::AsRef;
23use std::error::Error;
24use thiserror::Error;
25
26#[cfg(not(target_arch = "wasm32"))]
27use rayon::prelude::*;
28
29impl<'a> AsRef<[f64]> for PercentileNearestRankInput<'a> {
30    #[inline(always)]
31    fn as_ref(&self) -> &[f64] {
32        match &self.data {
33            PercentileNearestRankData::Slice(slice) => slice,
34            PercentileNearestRankData::Candles { candles, source } => source_type(candles, source),
35        }
36    }
37}
38
39#[derive(Debug, Clone)]
40pub enum PercentileNearestRankData<'a> {
41    Candles {
42        candles: &'a Candles,
43        source: &'a str,
44    },
45    Slice(&'a [f64]),
46}
47
48#[derive(Debug, Clone)]
49pub struct PercentileNearestRankOutput {
50    pub values: Vec<f64>,
51}
52
53#[derive(Debug, Clone)]
54#[cfg_attr(
55    all(target_arch = "wasm32", feature = "wasm"),
56    derive(Serialize, Deserialize)
57)]
58pub struct PercentileNearestRankParams {
59    pub length: Option<usize>,
60    pub percentage: Option<f64>,
61}
62
63impl Default for PercentileNearestRankParams {
64    fn default() -> Self {
65        Self {
66            length: Some(15),
67            percentage: Some(50.0),
68        }
69    }
70}
71
72#[derive(Debug, Clone)]
73pub struct PercentileNearestRankInput<'a> {
74    pub data: PercentileNearestRankData<'a>,
75    pub params: PercentileNearestRankParams,
76}
77
78impl<'a> PercentileNearestRankInput<'a> {
79    #[inline]
80    pub fn from_candles(c: &'a Candles, s: &'a str, p: PercentileNearestRankParams) -> Self {
81        Self {
82            data: PercentileNearestRankData::Candles {
83                candles: c,
84                source: s,
85            },
86            params: p,
87        }
88    }
89
90    #[inline]
91    pub fn from_slice(sl: &'a [f64], p: PercentileNearestRankParams) -> Self {
92        Self {
93            data: PercentileNearestRankData::Slice(sl),
94            params: p,
95        }
96    }
97
98    #[inline]
99    pub fn with_default_candles(c: &'a Candles) -> Self {
100        Self::from_candles(c, "close", PercentileNearestRankParams::default())
101    }
102
103    #[inline]
104    pub fn get_length(&self) -> usize {
105        self.params.length.unwrap_or(15)
106    }
107
108    #[inline]
109    pub fn get_percentage(&self) -> f64 {
110        self.params.percentage.unwrap_or(50.0)
111    }
112}
113
114#[derive(Debug, Error)]
115pub enum PercentileNearestRankError {
116    #[error("percentile_nearest_rank: Input data is empty")]
117    EmptyInputData,
118
119    #[error("percentile_nearest_rank: All values are NaN")]
120    AllValuesNaN,
121
122    #[error(
123        "percentile_nearest_rank: Invalid period: period = {period}, data length = {data_len}"
124    )]
125    InvalidPeriod { period: usize, data_len: usize },
126
127    #[error("percentile_nearest_rank: Percentage must be between 0 and 100, got {percentage}")]
128    InvalidPercentage { percentage: f64 },
129
130    #[error("percentile_nearest_rank: Not enough valid data: needed = {needed}, valid = {valid}")]
131    NotEnoughValidData { needed: usize, valid: usize },
132
133    #[error("percentile_nearest_rank: Output length mismatch: expected {expected}, got {got}")]
134    OutputLengthMismatch { expected: usize, got: usize },
135
136    #[error("percentile_nearest_rank: Invalid range: start={start}, end={end}, step={step}")]
137    InvalidRange {
138        start: String,
139        end: String,
140        step: String,
141    },
142
143    #[error("percentile_nearest_rank: Invalid kernel for batch: {0:?}")]
144    InvalidKernelForBatch(Kernel),
145}
146
147#[inline(always)]
148fn pnr_prepare<'a>(
149    input: &'a PercentileNearestRankInput,
150    kernel: Kernel,
151) -> Result<(&'a [f64], usize, f64, usize, Kernel), PercentileNearestRankError> {
152    let data: &[f64] = input.as_ref();
153    let len = data.len();
154    if len == 0 {
155        return Err(PercentileNearestRankError::EmptyInputData);
156    }
157
158    let first = data
159        .iter()
160        .position(|x| !x.is_nan())
161        .ok_or(PercentileNearestRankError::AllValuesNaN)?;
162
163    let length = input.get_length();
164    let percentage = input.get_percentage();
165
166    if length == 0 || length > len {
167        return Err(PercentileNearestRankError::InvalidPeriod {
168            period: length,
169            data_len: len,
170        });
171    }
172    if !(0.0..=100.0).contains(&percentage) || percentage.is_nan() || percentage.is_infinite() {
173        return Err(PercentileNearestRankError::InvalidPercentage { percentage });
174    }
175    if len - first < length {
176        return Err(PercentileNearestRankError::NotEnoughValidData {
177            needed: length,
178            valid: len - first,
179        });
180    }
181
182    let chosen = match kernel {
183        Kernel::Auto => Kernel::Scalar,
184        k => k,
185    };
186    Ok((data, length, percentage, first, chosen))
187}
188
189#[inline(always)]
190fn pnr_compute_into(
191    data: &[f64],
192    length: usize,
193    percentage: f64,
194    first: usize,
195    _kernel: Kernel,
196    out: &mut [f64],
197) {
198    let n = data.len();
199    if n == 0 {
200        return;
201    }
202    let start_i = first + length - 1;
203    if start_i >= n {
204        return;
205    }
206
207    let mut sorted: Vec<f64> = Vec::with_capacity(length);
208    let window_start0 = start_i + 1 - length;
209    for idx in window_start0..=start_i {
210        let v = data[idx];
211        if !v.is_nan() {
212            sorted.push(v);
213        }
214    }
215    sorted.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
216
217    let p_frac = percentage * 0.01;
218    let k_const_usize = {
219        let raw = (p_frac.mul_add(length as f64, 0.0)).round() as isize - 1;
220        let mut k = if raw <= 0 { 0usize } else { raw as usize };
221        if k >= length {
222            k = length - 1;
223        }
224        k
225    };
226    let mut i = start_i;
227    loop {
228        if sorted.is_empty() {
229            out[i] = f64::NAN;
230        } else {
231            let wl = sorted.len();
232            let idx = if wl == length {
233                k_const_usize
234            } else {
235                let raw = (p_frac.mul_add(wl as f64, 0.0)).round() as isize - 1;
236                let mut k = if raw <= 0 { 0usize } else { raw as usize };
237                if k >= wl {
238                    k = wl - 1;
239                }
240                k
241            };
242            out[i] = sorted[idx];
243        }
244
245        if i + 1 >= n {
246            break;
247        }
248
249        let out_idx = i + 1 - length;
250        let v_out = data[out_idx];
251        if !v_out.is_nan() {
252            if let Ok(pos) = sorted.binary_search_by(|x| x.partial_cmp(&v_out).unwrap()) {
253                sorted.remove(pos);
254            }
255        }
256        let v_in = data[i + 1];
257        if !v_in.is_nan() {
258            match sorted.binary_search_by(|x| x.partial_cmp(&v_in).unwrap()) {
259                Ok(pos) | Err(pos) => sorted.insert(pos, v_in),
260            }
261        }
262
263        i += 1;
264    }
265}
266
267#[inline]
268pub fn percentile_nearest_rank(
269    input: &PercentileNearestRankInput,
270) -> Result<PercentileNearestRankOutput, PercentileNearestRankError> {
271    percentile_nearest_rank_with_kernel(input, Kernel::Auto)
272}
273
274pub fn percentile_nearest_rank_with_kernel(
275    input: &PercentileNearestRankInput,
276    kernel: Kernel,
277) -> Result<PercentileNearestRankOutput, PercentileNearestRankError> {
278    let (data, length, percentage, first, chosen) = pnr_prepare(input, kernel)?;
279    let warmup_end = first + length - 1;
280    let mut out = alloc_with_nan_prefix(data.len(), warmup_end);
281    pnr_compute_into(data, length, percentage, first, chosen, &mut out);
282    Ok(PercentileNearestRankOutput { values: out })
283}
284
285#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
286#[inline]
287pub fn percentile_nearest_rank_into(
288    input: &PercentileNearestRankInput,
289    out: &mut [f64],
290) -> Result<(), PercentileNearestRankError> {
291    percentile_nearest_rank_into_slice(out, input, Kernel::Auto)
292}
293
294#[inline]
295pub fn percentile_nearest_rank_into_slice(
296    dst: &mut [f64],
297    input: &PercentileNearestRankInput,
298    kernel: Kernel,
299) -> Result<(), PercentileNearestRankError> {
300    let (data, length, percentage, first, chosen) = pnr_prepare(input, kernel)?;
301    if dst.len() != data.len() {
302        return Err(PercentileNearestRankError::OutputLengthMismatch {
303            expected: data.len(),
304            got: dst.len(),
305        });
306    }
307
308    pnr_compute_into(data, length, percentage, first, chosen, dst);
309
310    let warmup_end = first + length - 1;
311    for v in &mut dst[..warmup_end] {
312        *v = f64::NAN;
313    }
314    Ok(())
315}
316
317#[derive(Copy, Clone, Debug)]
318pub struct PercentileNearestRankBuilder {
319    length: Option<usize>,
320    percentage: Option<f64>,
321    kernel: Kernel,
322}
323
324impl Default for PercentileNearestRankBuilder {
325    fn default() -> Self {
326        Self {
327            length: None,
328            percentage: None,
329            kernel: Kernel::Auto,
330        }
331    }
332}
333
334impl PercentileNearestRankBuilder {
335    #[inline(always)]
336    pub fn new() -> Self {
337        Self::default()
338    }
339
340    #[inline(always)]
341    pub fn length(mut self, n: usize) -> Self {
342        self.length = Some(n);
343        self
344    }
345
346    #[inline(always)]
347    pub fn percentage(mut self, p: f64) -> Self {
348        self.percentage = Some(p);
349        self
350    }
351
352    #[inline(always)]
353    pub fn kernel(mut self, k: Kernel) -> Self {
354        self.kernel = k;
355        self
356    }
357
358    pub fn build(
359        self,
360        data: &[f64],
361    ) -> Result<PercentileNearestRankOutput, PercentileNearestRankError> {
362        let params = PercentileNearestRankParams {
363            length: self.length,
364            percentage: self.percentage,
365        };
366        let input = PercentileNearestRankInput::from_slice(data, params);
367        percentile_nearest_rank_with_kernel(&input, self.kernel)
368    }
369
370    pub fn build_candles(
371        self,
372        candles: &Candles,
373        source: &str,
374    ) -> Result<PercentileNearestRankOutput, PercentileNearestRankError> {
375        let params = PercentileNearestRankParams {
376            length: self.length,
377            percentage: self.percentage,
378        };
379        let input = PercentileNearestRankInput::from_candles(candles, source, params);
380        percentile_nearest_rank_with_kernel(&input, self.kernel)
381    }
382
383    #[inline(always)]
384    pub fn apply(
385        self,
386        c: &Candles,
387    ) -> Result<PercentileNearestRankOutput, PercentileNearestRankError> {
388        let p = PercentileNearestRankParams {
389            length: self.length,
390            percentage: self.percentage,
391        };
392        let i = PercentileNearestRankInput::from_candles(c, "close", p);
393        percentile_nearest_rank_with_kernel(&i, self.kernel)
394    }
395
396    #[inline(always)]
397    pub fn apply_slice(
398        self,
399        d: &[f64],
400    ) -> Result<PercentileNearestRankOutput, PercentileNearestRankError> {
401        let p = PercentileNearestRankParams {
402            length: self.length,
403            percentage: self.percentage,
404        };
405        let i = PercentileNearestRankInput::from_slice(d, p);
406        percentile_nearest_rank_with_kernel(&i, self.kernel)
407    }
408
409    #[inline(always)]
410    pub fn into_stream(self) -> Result<PercentileNearestRankStream, PercentileNearestRankError> {
411        let p = PercentileNearestRankParams {
412            length: self.length,
413            percentage: self.percentage,
414        };
415        PercentileNearestRankStream::try_new(p)
416    }
417
418    pub fn with_default_slice(
419        data: &[f64],
420        k: Kernel,
421    ) -> Result<PercentileNearestRankOutput, PercentileNearestRankError> {
422        Self::new().kernel(k).apply_slice(data)
423    }
424
425    pub fn with_default_candles(
426        c: &Candles,
427    ) -> Result<PercentileNearestRankOutput, PercentileNearestRankError> {
428        Self::new().kernel(Kernel::Auto).apply(c)
429    }
430}
431
432use std::cmp::Reverse;
433use std::collections::HashMap;
434
435#[derive(Copy, Clone, Debug)]
436struct FOrd(f64);
437impl PartialEq for FOrd {
438    #[inline]
439    fn eq(&self, other: &Self) -> bool {
440        self.0 == other.0
441    }
442}
443impl Eq for FOrd {}
444impl PartialOrd for FOrd {
445    #[inline]
446    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
447        Some(self.cmp(other))
448    }
449}
450impl Ord for FOrd {
451    #[inline]
452    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
453        self.0.partial_cmp(&other.0).unwrap()
454    }
455}
456
457#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
458struct FKey(u64);
459impl From<f64> for FKey {
460    #[inline]
461    fn from(x: f64) -> Self {
462        let bits = if x == 0.0 { 0u64 } else { x.to_bits() };
463        FKey(bits)
464    }
465}
466
467#[derive(Debug, Clone)]
468pub struct PercentileNearestRankStream {
469    length: usize,
470    percentage: f64,
471    p_frac: f64,
472    buffer: Vec<f64>,
473    head: usize,
474    filled: bool,
475
476    left: std::collections::BinaryHeap<FOrd>,
477    right: std::collections::BinaryHeap<Reverse<FOrd>>,
478    delayed_left: HashMap<FKey, usize>,
479    delayed_right: HashMap<FKey, usize>,
480    size_left: usize,
481    size_right: usize,
482
483    t_full: usize,
484}
485
486#[inline(always)]
487fn nearest_rank_index_fast(pf: f64, wl: usize) -> usize {
488    let mut k = (pf.mul_add(wl as f64, 0.5)) as usize;
489    if k == 0 {
490        0
491    } else {
492        k -= 1;
493        if k >= wl {
494            wl - 1
495        } else {
496            k
497        }
498    }
499}
500
501impl PercentileNearestRankStream {
502    pub fn try_new(
503        params: PercentileNearestRankParams,
504    ) -> Result<Self, PercentileNearestRankError> {
505        let length = params.length.unwrap_or(15);
506        if length == 0 {
507            return Err(PercentileNearestRankError::InvalidPeriod {
508                period: length,
509                data_len: 0,
510            });
511        }
512        let percentage = params.percentage.unwrap_or(50.0);
513        if !(0.0..=100.0).contains(&percentage) || percentage.is_nan() || percentage.is_infinite() {
514            return Err(PercentileNearestRankError::InvalidPercentage { percentage });
515        }
516
517        let p_frac = percentage * 0.01;
518        let t_full = nearest_rank_index_fast(p_frac, length) + 1;
519
520        Ok(Self {
521            length,
522            percentage,
523            p_frac,
524            buffer: vec![f64::NAN; length],
525            head: 0,
526            filled: false,
527            left: std::collections::BinaryHeap::with_capacity(length),
528            right: std::collections::BinaryHeap::with_capacity(length),
529            delayed_left: HashMap::new(),
530            delayed_right: HashMap::new(),
531            size_left: 0,
532            size_right: 0,
533            t_full,
534        })
535    }
536
537    #[inline(always)]
538    fn prune_left(&mut self) {
539        while let Some(&FOrd(x)) = self.left.peek() {
540            let key = FKey::from(x);
541            if let Some(cnt) = self.delayed_left.get_mut(&key) {
542                if *cnt > 0 {
543                    self.left.pop();
544                    *cnt -= 1;
545                    if *cnt == 0 {
546                        self.delayed_left.remove(&key);
547                    }
548                } else {
549                    break;
550                }
551            } else {
552                break;
553            }
554        }
555    }
556
557    #[inline(always)]
558    fn prune_right(&mut self) {
559        while let Some(&Reverse(FOrd(x))) = self.right.peek() {
560            let key = FKey::from(x);
561            if let Some(cnt) = self.delayed_right.get_mut(&key) {
562                if *cnt > 0 {
563                    self.right.pop();
564                    *cnt -= 1;
565                    if *cnt == 0 {
566                        self.delayed_right.remove(&key);
567                    }
568                } else {
569                    break;
570                }
571            } else {
572                break;
573            }
574        }
575    }
576
577    #[inline(always)]
578    fn current_left_top(&mut self) -> Option<f64> {
579        self.prune_left();
580        self.left.peek().map(|v| v.0)
581    }
582
583    #[inline(always)]
584    fn push_value(&mut self, v: f64) {
585        if v.is_nan() {
586            return;
587        }
588        if self.size_left == 0 {
589            self.left.push(FOrd(v));
590            self.size_left += 1;
591        } else {
592            let left_top = self.current_left_top().unwrap();
593            if v <= left_top {
594                self.left.push(FOrd(v));
595                self.size_left += 1;
596            } else {
597                self.right.push(Reverse(FOrd(v)));
598                self.size_right += 1;
599            }
600        }
601    }
602
603    #[inline(always)]
604    fn erase_value(&mut self, v: f64) {
605        if v.is_nan() {
606            return;
607        }
608        let belongs_left = match self.current_left_top() {
609            Some(top) => v <= top,
610            None => false,
611        };
612        let key = FKey::from(v);
613        if belongs_left {
614            *self.delayed_left.entry(key).or_insert(0) += 1;
615            if self.size_left > 0 {
616                self.size_left -= 1;
617            }
618            self.prune_left();
619        } else {
620            *self.delayed_right.entry(key).or_insert(0) += 1;
621            if self.size_right > 0 {
622                self.size_right -= 1;
623            }
624            self.prune_right();
625        }
626    }
627
628    #[inline(always)]
629    fn target_left_for_valid(&self, valid: usize) -> usize {
630        if valid == 0 {
631            return 0;
632        }
633        if valid == self.length {
634            return self.t_full;
635        }
636        nearest_rank_index_fast(self.p_frac, valid) + 1
637    }
638
639    #[inline(always)]
640    fn rebalance(&mut self, target_left: usize) {
641        self.prune_left();
642        self.prune_right();
643
644        while self.size_left > target_left {
645            if let Some(FOrd(x)) = self.left.pop() {
646                self.size_left -= 1;
647                self.right.push(Reverse(FOrd(x)));
648                self.size_right += 1;
649            }
650            self.prune_left();
651        }
652        while self.size_left < target_left {
653            self.prune_right();
654            if let Some(Reverse(FOrd(x))) = self.right.pop() {
655                self.size_right -= 1;
656                self.left.push(FOrd(x));
657                self.size_left += 1;
658            } else {
659                break;
660            }
661        }
662        self.prune_left();
663    }
664
665    pub fn update(&mut self, value: f64) -> Option<f64> {
666        let outgoing = self.buffer[self.head];
667        self.buffer[self.head] = value;
668        self.head = (self.head + 1) % self.length;
669
670        if !self.filled && self.head == 0 {
671            self.filled = true;
672        }
673
674        self.push_value(value);
675
676        if !self.filled {
677            return None;
678        }
679
680        self.erase_value(outgoing);
681
682        let valid = self.size_left + self.size_right;
683        if valid == 0 {
684            return Some(f64::NAN);
685        }
686        let target_left = self.target_left_for_valid(valid);
687        self.rebalance(target_left);
688
689        self.current_left_top().or(Some(f64::NAN))
690    }
691}
692
693#[cfg(feature = "python")]
694#[pyfunction(name = "percentile_nearest_rank")]
695#[pyo3(signature = (data, length=15, percentage=50.0, kernel=None))]
696pub fn percentile_nearest_rank_py<'py>(
697    py: Python<'py>,
698    data: PyReadonlyArray1<'py, f64>,
699    length: usize,
700    percentage: f64,
701    kernel: Option<&str>,
702) -> PyResult<Bound<'py, PyArray1<f64>>> {
703    let kern = validate_kernel(kernel, false)?;
704    let data_slice = data.as_slice()?;
705
706    let params = PercentileNearestRankParams {
707        length: Some(length),
708        percentage: Some(percentage),
709    };
710    let input = PercentileNearestRankInput::from_slice(data_slice, params);
711
712    let result = py
713        .allow_threads(|| percentile_nearest_rank_with_kernel(&input, kern))
714        .map_err(|e| PyValueError::new_err(e.to_string()))?;
715
716    Ok(result.values.into_pyarray(py))
717}
718
719#[cfg(feature = "python")]
720#[pyclass(name = "PercentileNearestRankStream")]
721pub struct PercentileNearestRankStreamPy {
722    stream: PercentileNearestRankStream,
723}
724
725#[cfg(feature = "python")]
726#[pymethods]
727impl PercentileNearestRankStreamPy {
728    #[new]
729    fn new(length: usize, percentage: f64) -> PyResult<Self> {
730        let params = PercentileNearestRankParams {
731            length: Some(length),
732            percentage: Some(percentage),
733        };
734        let stream = PercentileNearestRankStream::try_new(params)
735            .map_err(|e| PyValueError::new_err(e.to_string()))?;
736        Ok(PercentileNearestRankStreamPy { stream })
737    }
738
739    fn update(&mut self, value: f64) -> Option<f64> {
740        self.stream.update(value)
741    }
742}
743
744#[cfg(feature = "python")]
745#[pyfunction(name = "percentile_nearest_rank_batch")]
746#[pyo3(signature = (data, length_range, percentage_range, kernel=None))]
747pub fn percentile_nearest_rank_batch_py<'py>(
748    py: Python<'py>,
749    data: numpy::PyReadonlyArray1<'py, f64>,
750    length_range: (usize, usize, usize),
751    percentage_range: (f64, f64, f64),
752    kernel: Option<&str>,
753) -> PyResult<Bound<'py, pyo3::types::PyDict>> {
754    use numpy::{IntoPyArray, PyArray1, PyArrayMethods};
755    use pyo3::types::PyDict;
756
757    let slice_in = data.as_slice()?;
758    let sweep = PercentileNearestRankBatchRange {
759        length: length_range,
760        percentage: percentage_range,
761    };
762
763    let combos = expand_grid_pnr(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
764    let rows = combos.len();
765    let cols = slice_in.len();
766
767    let total = rows
768        .checked_mul(cols)
769        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
770    let out_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
771    let slice_out = unsafe { out_arr.as_slice_mut()? };
772
773    for (row_idx, combo) in combos.iter().enumerate() {
774        let length = combo.length.unwrap_or(15);
775        let warmup = length - 1;
776        let row_start = row_idx * cols;
777        for i in 0..warmup.min(cols) {
778            slice_out[row_start + i] = f64::NAN;
779        }
780    }
781
782    let kern = validate_kernel(kernel, true)?;
783    py.allow_threads(|| {
784        let k = match kern {
785            Kernel::Auto => detect_best_batch_kernel(),
786            k => k,
787        };
788
789        pnr_batch_inner_into(slice_in, &combos, k, true, slice_out)
790    })
791    .map_err(|e| PyValueError::new_err(e.to_string()))?;
792
793    let dict = PyDict::new(py);
794    dict.set_item("values", out_arr.reshape((rows, cols))?)?;
795    dict.set_item(
796        "lengths",
797        combos
798            .iter()
799            .map(|p| p.length.unwrap_or(15) as u64)
800            .collect::<Vec<_>>()
801            .into_pyarray(py),
802    )?;
803    dict.set_item(
804        "percentages",
805        combos
806            .iter()
807            .map(|p| p.percentage.unwrap_or(50.0))
808            .collect::<Vec<_>>()
809            .into_pyarray(py),
810    )?;
811    Ok(dict.into())
812}
813
814#[derive(Clone, Debug)]
815pub struct PercentileNearestRankBatchRange {
816    pub length: (usize, usize, usize),
817    pub percentage: (f64, f64, f64),
818}
819
820impl Default for PercentileNearestRankBatchRange {
821    fn default() -> Self {
822        Self {
823            length: (15, 264, 1),
824            percentage: (50.0, 50.0, 0.0),
825        }
826    }
827}
828
829#[derive(Clone, Debug, Default)]
830pub struct PercentileNearestRankBatchBuilder {
831    range: PercentileNearestRankBatchRange,
832    kernel: Kernel,
833}
834
835impl PercentileNearestRankBatchBuilder {
836    pub fn new() -> Self {
837        Self::default()
838    }
839
840    pub fn kernel(mut self, k: Kernel) -> Self {
841        self.kernel = k;
842        self
843    }
844
845    pub fn period_range(mut self, start: usize, end: usize, step: usize) -> Self {
846        self.range.length = (start, end, step);
847        self
848    }
849
850    pub fn percentage_range(mut self, start: f64, end: f64, step: f64) -> Self {
851        self.range.percentage = (start, end, step);
852        self
853    }
854
855    pub fn apply(
856        self,
857        data: &[f64],
858    ) -> Result<PercentileNearestRankBatchOutput, PercentileNearestRankError> {
859        pnr_batch_slice(data, &self.range, self.kernel)
860    }
861
862    pub fn apply_candles(
863        self,
864        candles: &Candles,
865        source: &str,
866    ) -> Result<PercentileNearestRankBatchOutput, PercentileNearestRankError> {
867        let data = source_type(candles, source);
868        pnr_batch_slice(data, &self.range, self.kernel)
869    }
870
871    pub fn with_default_slice(
872        data: &[f64],
873        k: Kernel,
874    ) -> Result<PercentileNearestRankBatchOutput, PercentileNearestRankError> {
875        PercentileNearestRankBatchBuilder::new()
876            .kernel(k)
877            .apply(data)
878    }
879
880    pub fn with_default_candles(
881        c: &Candles,
882    ) -> Result<PercentileNearestRankBatchOutput, PercentileNearestRankError> {
883        PercentileNearestRankBatchBuilder::new()
884            .kernel(Kernel::Auto)
885            .apply_candles(c, "close")
886    }
887}
888
889#[derive(Clone, Debug)]
890pub struct PercentileNearestRankBatchOutput {
891    pub values: Vec<f64>,
892    pub combos: Vec<PercentileNearestRankParams>,
893    pub rows: usize,
894    pub cols: usize,
895}
896
897impl PercentileNearestRankBatchOutput {
898    pub fn row_for_params(&self, p: &PercentileNearestRankParams) -> Option<usize> {
899        self.combos.iter().position(|c| {
900            c.length.unwrap_or(15) == p.length.unwrap_or(15)
901                && (c.percentage.unwrap_or(50.0) - p.percentage.unwrap_or(50.0)).abs() < 1e-12
902        })
903    }
904
905    pub fn values_for(&self, p: &PercentileNearestRankParams) -> Option<&[f64]> {
906        self.row_for_params(p).map(|row| {
907            let start = row * self.cols;
908            &self.values[start..start + self.cols]
909        })
910    }
911}
912
913#[inline(always)]
914fn expand_grid_pnr(
915    r: &PercentileNearestRankBatchRange,
916) -> Result<Vec<PercentileNearestRankParams>, PercentileNearestRankError> {
917    fn axis_usize(
918        (start, end, step): (usize, usize, usize),
919    ) -> Result<Vec<usize>, PercentileNearestRankError> {
920        if step == 0 || start == end {
921            return Ok(vec![start]);
922        }
923        if start < end {
924            return Ok((start..=end).step_by(step.max(1)).collect());
925        }
926
927        let mut v = Vec::new();
928        let mut x = start as isize;
929        let end_i = end as isize;
930        let st = (step as isize).max(1);
931        while x >= end_i {
932            v.push(x as usize);
933            x -= st;
934        }
935        if v.is_empty() {
936            return Err(PercentileNearestRankError::InvalidRange {
937                start: start.to_string(),
938                end: end.to_string(),
939                step: step.to_string(),
940            });
941        }
942        Ok(v)
943    }
944
945    fn axis_f64(
946        (start, end, step): (f64, f64, f64),
947    ) -> Result<Vec<f64>, PercentileNearestRankError> {
948        if step.abs() < 1e-12 || (start - end).abs() < 1e-12 {
949            return Ok(vec![start]);
950        }
951        if start < end {
952            let mut v = Vec::new();
953            let mut x = start;
954            let st = step.abs();
955            while x <= end + 1e-12 {
956                v.push(x);
957                x += st;
958            }
959            if v.is_empty() {
960                return Err(PercentileNearestRankError::InvalidRange {
961                    start: start.to_string(),
962                    end: end.to_string(),
963                    step: step.to_string(),
964                });
965            }
966            return Ok(v);
967        }
968        let mut v = Vec::new();
969        let mut x = start;
970        let st = step.abs();
971        while x + 1e-12 >= end {
972            v.push(x);
973            x -= st;
974        }
975        if v.is_empty() {
976            return Err(PercentileNearestRankError::InvalidRange {
977                start: start.to_string(),
978                end: end.to_string(),
979                step: step.to_string(),
980            });
981        }
982        Ok(v)
983    }
984
985    let lengths = axis_usize(r.length)?;
986    let percentages = axis_f64(r.percentage)?;
987
988    let cap = lengths
989        .len()
990        .checked_mul(percentages.len())
991        .ok_or_else(|| PercentileNearestRankError::InvalidRange {
992            start: "cap".into(),
993            end: "overflow".into(),
994            step: "mul".into(),
995        })?;
996
997    if cap == 0 {
998        return Err(PercentileNearestRankError::InvalidRange {
999            start: "range".into(),
1000            end: "range".into(),
1001            step: "empty".into(),
1002        });
1003    }
1004
1005    let mut combos = Vec::with_capacity(cap);
1006    for &length in &lengths {
1007        for &percentage in &percentages {
1008            combos.push(PercentileNearestRankParams {
1009                length: Some(length),
1010                percentage: Some(percentage),
1011            });
1012        }
1013    }
1014    Ok(combos)
1015}
1016
1017#[inline(always)]
1018pub fn pnr_batch_with_kernel(
1019    data: &[f64],
1020    sweep: &PercentileNearestRankBatchRange,
1021    k: Kernel,
1022) -> Result<PercentileNearestRankBatchOutput, PercentileNearestRankError> {
1023    let kernel = match k {
1024        Kernel::Auto => detect_best_batch_kernel(),
1025        other if other.is_batch() => other,
1026        _ => return Err(PercentileNearestRankError::InvalidKernelForBatch(k)),
1027    };
1028    pnr_batch_inner(data, sweep, kernel, true)
1029}
1030
1031#[inline(always)]
1032pub fn pnr_batch_slice(
1033    data: &[f64],
1034    sweep: &PercentileNearestRankBatchRange,
1035    k: Kernel,
1036) -> Result<PercentileNearestRankBatchOutput, PercentileNearestRankError> {
1037    pnr_batch_inner(data, sweep, k, false)
1038}
1039
1040#[inline(always)]
1041pub fn pnr_batch_par_slice(
1042    data: &[f64],
1043    sweep: &PercentileNearestRankBatchRange,
1044    k: Kernel,
1045) -> Result<PercentileNearestRankBatchOutput, PercentileNearestRankError> {
1046    pnr_batch_inner(data, sweep, k, true)
1047}
1048
1049#[inline(always)]
1050fn pnr_batch_inner(
1051    data: &[f64],
1052    sweep: &PercentileNearestRankBatchRange,
1053    kern: Kernel,
1054    parallel: bool,
1055) -> Result<PercentileNearestRankBatchOutput, PercentileNearestRankError> {
1056    if data.is_empty() {
1057        return Err(PercentileNearestRankError::EmptyInputData);
1058    }
1059    let combos = expand_grid_pnr(sweep)?;
1060    if combos.is_empty() {
1061        return Err(PercentileNearestRankError::InvalidRange {
1062            start: "range".into(),
1063            end: "range".into(),
1064            step: "empty".into(),
1065        });
1066    }
1067    let rows = combos.len();
1068    let cols = data.len();
1069
1070    let first = data
1071        .iter()
1072        .position(|x| !x.is_nan())
1073        .ok_or(PercentileNearestRankError::AllValuesNaN)?;
1074    let max_len = combos.iter().map(|c| c.length.unwrap()).max().unwrap();
1075    if data.len() - first < max_len {
1076        return Err(PercentileNearestRankError::NotEnoughValidData {
1077            needed: max_len,
1078            valid: data.len() - first,
1079        });
1080    }
1081
1082    let _ = rows
1083        .checked_mul(cols)
1084        .ok_or_else(|| PercentileNearestRankError::InvalidRange {
1085            start: rows.to_string(),
1086            end: cols.to_string(),
1087            step: "rows*cols".into(),
1088        })?;
1089
1090    let mut buf_mu = make_uninit_matrix(rows, cols);
1091    let warm: Vec<usize> = combos
1092        .iter()
1093        .map(|c| first + c.length.unwrap() - 1)
1094        .collect();
1095    init_matrix_prefixes(&mut buf_mu, cols, &warm);
1096
1097    let mut guard = core::mem::ManuallyDrop::new(buf_mu);
1098    let out: &mut [f64] =
1099        unsafe { core::slice::from_raw_parts_mut(guard.as_mut_ptr() as *mut f64, guard.len()) };
1100
1101    pnr_batch_inner_into(data, &combos, kern, parallel, out)?;
1102
1103    let values = unsafe {
1104        Vec::from_raw_parts(
1105            guard.as_mut_ptr() as *mut f64,
1106            guard.len(),
1107            guard.capacity(),
1108        )
1109    };
1110
1111    Ok(PercentileNearestRankBatchOutput {
1112        values,
1113        combos,
1114        rows,
1115        cols,
1116    })
1117}
1118
1119#[inline(always)]
1120fn pnr_batch_inner_into(
1121    data: &[f64],
1122    combos: &[PercentileNearestRankParams],
1123    kern: Kernel,
1124    parallel: bool,
1125    out: &mut [f64],
1126) -> Result<(), PercentileNearestRankError> {
1127    let cols = data.len();
1128    if cols == 0 {
1129        return Err(PercentileNearestRankError::EmptyInputData);
1130    }
1131
1132    let first = data
1133        .iter()
1134        .position(|x| !x.is_nan())
1135        .ok_or(PercentileNearestRankError::AllValuesNaN)?;
1136    let chosen = match kern {
1137        Kernel::Auto => Kernel::Scalar,
1138        k => k,
1139    };
1140
1141    use std::collections::HashMap;
1142    let mut by_len: HashMap<usize, Vec<(usize, f64)>> = HashMap::new();
1143    for (row, p) in combos.iter().enumerate() {
1144        let len = p.length.unwrap_or(15);
1145        let perc = p.percentage.unwrap_or(50.0);
1146        by_len.entry(len).or_default().push((row, perc));
1147    }
1148
1149    let has_benefit = by_len.values().any(|v| v.len() > 1);
1150
1151    if parallel || !has_benefit {
1152        let do_row = |row: usize, dst_row: &mut [f64]| {
1153            let length = combos[row].length.unwrap_or(15);
1154            let percentage = combos[row].percentage.unwrap_or(50.0);
1155
1156            pnr_compute_into(data, length, percentage, first, chosen, dst_row);
1157        };
1158
1159        if parallel {
1160            #[cfg(not(target_arch = "wasm32"))]
1161            {
1162                use rayon::prelude::*;
1163                out.par_chunks_mut(cols)
1164                    .enumerate()
1165                    .for_each(|(row, s)| do_row(row, s));
1166            }
1167            #[cfg(target_arch = "wasm32")]
1168            for (row, s) in out.chunks_mut(cols).enumerate() {
1169                do_row(row, s);
1170            }
1171        } else {
1172            for (row, s) in out.chunks_mut(cols).enumerate() {
1173                do_row(row, s);
1174            }
1175        }
1176    } else {
1177        for (length, rows) in by_len.into_iter() {
1178            let start_i = first + length - 1;
1179            if start_i >= cols {
1180                continue;
1181            }
1182
1183            let mut rows_info: Vec<(usize, f64, usize)> = Vec::with_capacity(rows.len());
1184            for &(row, perc) in &rows {
1185                let p_frac = perc * 0.01;
1186                let raw = (p_frac.mul_add(length as f64, 0.0)).round() as isize - 1;
1187                let mut k = if raw <= 0 { 0usize } else { raw as usize };
1188                if k >= length {
1189                    k = length - 1;
1190                }
1191                rows_info.push((row, p_frac, k));
1192            }
1193
1194            let mut sorted: Vec<f64> = Vec::with_capacity(length);
1195            let window_start0 = start_i + 1 - length;
1196            for idx in window_start0..=start_i {
1197                let v = data[idx];
1198                if !v.is_nan() {
1199                    sorted.push(v);
1200                }
1201            }
1202            sorted.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
1203
1204            let mut i = start_i;
1205            loop {
1206                if sorted.is_empty() {
1207                    for &(row, _, _) in &rows_info {
1208                        out[row * cols + i] = f64::NAN;
1209                    }
1210                } else {
1211                    let wl = sorted.len();
1212                    let full = wl == length;
1213                    for &(row, p_frac, k_const) in &rows_info {
1214                        let idx = if full {
1215                            k_const
1216                        } else {
1217                            let raw = (p_frac.mul_add(wl as f64, 0.0)).round() as isize - 1;
1218                            let mut k = if raw <= 0 { 0usize } else { raw as usize };
1219                            if k >= wl {
1220                                k = wl - 1;
1221                            }
1222                            k
1223                        };
1224                        out[row * cols + i] = sorted[idx];
1225                    }
1226                }
1227
1228                if i + 1 >= cols {
1229                    break;
1230                }
1231
1232                let out_idx = i + 1 - length;
1233                let v_out = data[out_idx];
1234                if !v_out.is_nan() {
1235                    if let Ok(pos) = sorted.binary_search_by(|x| x.partial_cmp(&v_out).unwrap()) {
1236                        sorted.remove(pos);
1237                    }
1238                }
1239                let v_in = data[i + 1];
1240                if !v_in.is_nan() {
1241                    match sorted.binary_search_by(|x| x.partial_cmp(&v_in).unwrap()) {
1242                        Ok(pos) | Err(pos) => sorted.insert(pos, v_in),
1243                    }
1244                }
1245
1246                i += 1;
1247            }
1248        }
1249    }
1250    Ok(())
1251}
1252
1253#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1254#[wasm_bindgen]
1255pub fn percentile_nearest_rank_js(
1256    data: &[f64],
1257    length: usize,
1258    percentage: f64,
1259) -> Result<Vec<f64>, JsValue> {
1260    let params = PercentileNearestRankParams {
1261        length: Some(length),
1262        percentage: Some(percentage),
1263    };
1264    let input = PercentileNearestRankInput::from_slice(data, params);
1265    percentile_nearest_rank(&input)
1266        .map(|o| o.values)
1267        .map_err(|e| JsValue::from_str(&e.to_string()))
1268}
1269
1270#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1271#[wasm_bindgen]
1272pub fn percentile_nearest_rank_alloc(n: usize) -> *mut f64 {
1273    let mut v = Vec::<f64>::with_capacity(n);
1274    let p = v.as_mut_ptr();
1275    core::mem::forget(v);
1276    p
1277}
1278
1279#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1280#[wasm_bindgen]
1281pub fn percentile_nearest_rank_free(ptr: *mut f64, n: usize) {
1282    unsafe {
1283        let _ = Vec::from_raw_parts(ptr, n, n);
1284    }
1285}
1286
1287#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1288#[wasm_bindgen]
1289pub fn percentile_nearest_rank_into(
1290    data_ptr: *const f64,
1291    out_ptr: *mut f64,
1292    len: usize,
1293    length: usize,
1294    percentage: f64,
1295) -> Result<(), JsValue> {
1296    if data_ptr.is_null() || out_ptr.is_null() {
1297        return Err(JsValue::from_str("null pointer"));
1298    }
1299    unsafe {
1300        let data = std::slice::from_raw_parts(data_ptr, len);
1301        let params = PercentileNearestRankParams {
1302            length: Some(length),
1303            percentage: Some(percentage),
1304        };
1305        let input = PercentileNearestRankInput::from_slice(data, params);
1306
1307        if data_ptr == out_ptr {
1308            let mut temp = vec![0.0; len];
1309            percentile_nearest_rank_into_slice(&mut temp, &input, detect_best_kernel())
1310                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1311            let out = std::slice::from_raw_parts_mut(out_ptr, len);
1312            out.copy_from_slice(&temp);
1313        } else {
1314            let out = std::slice::from_raw_parts_mut(out_ptr, len);
1315            percentile_nearest_rank_into_slice(out, &input, detect_best_kernel())
1316                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1317        }
1318        Ok(())
1319    }
1320}
1321
1322#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1323#[derive(Serialize, Deserialize)]
1324pub struct PercentileNearestRankBatchConfig {
1325    pub length_range: (usize, usize, usize),
1326    pub percentage_range: (f64, f64, f64),
1327}
1328
1329#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1330#[derive(Serialize, Deserialize)]
1331pub struct PercentileNearestRankBatchJsOutput {
1332    pub values: Vec<f64>,
1333    pub combos: Vec<PercentileNearestRankParams>,
1334    pub rows: usize,
1335    pub cols: usize,
1336}
1337
1338#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1339#[wasm_bindgen(js_name = percentile_nearest_rank_batch)]
1340pub fn percentile_nearest_rank_batch_unified_js(
1341    data: &[f64],
1342    config: JsValue,
1343) -> Result<JsValue, JsValue> {
1344    let cfg: PercentileNearestRankBatchConfig = serde_wasm_bindgen::from_value(config)
1345        .map_err(|e| JsValue::from_str(&format!("Invalid config: {}", e)))?;
1346    let sweep = PercentileNearestRankBatchRange {
1347        length: cfg.length_range,
1348        percentage: cfg.percentage_range,
1349    };
1350    let out = pnr_batch_inner(data, &sweep, detect_best_batch_kernel(), false)
1351        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1352    let js = PercentileNearestRankBatchJsOutput {
1353        values: out.values,
1354        combos: out.combos,
1355        rows: out.rows,
1356        cols: out.cols,
1357    };
1358    serde_wasm_bindgen::to_value(&js)
1359        .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364    use super::*;
1365    use crate::utilities::data_loader::read_candles_from_csv;
1366    use std::error::Error;
1367
1368    macro_rules! skip_if_unsupported {
1369        ($kernel:expr, $test_name:expr) => {
1370            #[cfg(not(all(feature = "nightly-avx", target_arch = "x86_64")))]
1371            match $kernel {
1372                Kernel::Avx2 | Kernel::Avx512 | Kernel::Avx2Batch | Kernel::Avx512Batch => {
1373                    println!("[{}] Skipping: AVX not supported", $test_name);
1374                    return Ok(());
1375                }
1376                _ => {}
1377            }
1378        };
1379    }
1380
1381    fn check_pnr_accuracy(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1382        skip_if_unsupported!(kernel, test_name);
1383
1384        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1385        let candles = read_candles_from_csv(file_path)?;
1386
1387        let params = PercentileNearestRankParams {
1388            length: Some(15),
1389            percentage: Some(50.0),
1390        };
1391        let input = PercentileNearestRankInput::from_candles(&candles, "close", params);
1392        let result = percentile_nearest_rank_with_kernel(&input, kernel)?;
1393
1394        assert_eq!(result.values.len(), candles.close.len());
1395
1396        for i in 0..14 {
1397            assert!(
1398                result.values[i].is_nan(),
1399                "[{}] Expected NaN at index {}",
1400                test_name,
1401                i
1402            );
1403        }
1404
1405        assert!(
1406            !result.values[14].is_nan(),
1407            "[{}] Expected valid value at index 14",
1408            test_name
1409        );
1410
1411        let expected_last_5 = vec![59419.0, 59419.0, 59300.0, 59285.0, 59273.0];
1412        let len = result.values.len();
1413        let actual_last_5 = &result.values[len - 5..];
1414
1415        for (i, (&actual, &expected)) in
1416            actual_last_5.iter().zip(expected_last_5.iter()).enumerate()
1417        {
1418            let diff = (actual - expected).abs();
1419            assert!(
1420                diff < 1e-6,
1421                "[{}] Value mismatch at last_5[{}]: expected {}, got {}, diff {}",
1422                test_name,
1423                i,
1424                expected,
1425                actual,
1426                diff
1427            );
1428        }
1429
1430        Ok(())
1431    }
1432
1433    fn check_pnr_partial_params(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1434        skip_if_unsupported!(kernel, test_name);
1435
1436        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
1437
1438        let params = PercentileNearestRankParams {
1439            length: Some(5),
1440            percentage: None,
1441        };
1442        let input = PercentileNearestRankInput::from_slice(&data, params);
1443        let result = percentile_nearest_rank_with_kernel(&input, kernel)?;
1444
1445        assert_eq!(result.values.len(), data.len());
1446        assert_eq!(result.values[4], 3.0);
1447
1448        Ok(())
1449    }
1450
1451    fn check_pnr_default_candles(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1452        skip_if_unsupported!(kernel, test_name);
1453
1454        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1455        let candles = read_candles_from_csv(file_path)?;
1456
1457        let input = PercentileNearestRankInput::with_default_candles(&candles);
1458        let result = percentile_nearest_rank_with_kernel(&input, kernel)?;
1459
1460        assert_eq!(result.values.len(), candles.close.len());
1461        Ok(())
1462    }
1463
1464    fn check_pnr_zero_period(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1465        skip_if_unsupported!(kernel, test_name);
1466
1467        let data = vec![1.0; 10];
1468        let params = PercentileNearestRankParams {
1469            length: Some(0),
1470            percentage: Some(50.0),
1471        };
1472        let input = PercentileNearestRankInput::from_slice(&data, params);
1473        let result = percentile_nearest_rank_with_kernel(&input, kernel);
1474
1475        assert!(matches!(
1476            result,
1477            Err(PercentileNearestRankError::InvalidPeriod { .. })
1478        ));
1479        Ok(())
1480    }
1481
1482    fn check_pnr_period_exceeds_length(
1483        test_name: &str,
1484        kernel: Kernel,
1485    ) -> Result<(), Box<dyn Error>> {
1486        skip_if_unsupported!(kernel, test_name);
1487
1488        let data = vec![1.0; 5];
1489        let params = PercentileNearestRankParams {
1490            length: Some(10),
1491            percentage: Some(50.0),
1492        };
1493        let input = PercentileNearestRankInput::from_slice(&data, params);
1494        let result = percentile_nearest_rank_with_kernel(&input, kernel);
1495
1496        assert!(matches!(
1497            result,
1498            Err(PercentileNearestRankError::InvalidPeriod { .. })
1499        ));
1500        Ok(())
1501    }
1502
1503    fn check_pnr_very_small_dataset(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1504        skip_if_unsupported!(kernel, test_name);
1505
1506        let data = vec![5.0];
1507        let params = PercentileNearestRankParams {
1508            length: Some(1),
1509            percentage: Some(50.0),
1510        };
1511        let input = PercentileNearestRankInput::from_slice(&data, params);
1512        let result = percentile_nearest_rank_with_kernel(&input, kernel)?;
1513
1514        assert_eq!(result.values.len(), 1);
1515        assert_eq!(result.values[0], 5.0);
1516        Ok(())
1517    }
1518
1519    fn check_pnr_empty_input(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1520        skip_if_unsupported!(kernel, test_name);
1521
1522        let data: Vec<f64> = vec![];
1523        let params = PercentileNearestRankParams::default();
1524        let input = PercentileNearestRankInput::from_slice(&data, params);
1525        let result = percentile_nearest_rank_with_kernel(&input, kernel);
1526
1527        assert!(matches!(
1528            result,
1529            Err(PercentileNearestRankError::EmptyInputData)
1530        ));
1531        Ok(())
1532    }
1533
1534    fn check_pnr_invalid_percentage(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1535        skip_if_unsupported!(kernel, test_name);
1536
1537        let data = vec![1.0; 20];
1538
1539        let params = PercentileNearestRankParams {
1540            length: Some(5),
1541            percentage: Some(150.0),
1542        };
1543        let input = PercentileNearestRankInput::from_slice(&data, params);
1544        let result = percentile_nearest_rank_with_kernel(&input, kernel);
1545        assert!(matches!(
1546            result,
1547            Err(PercentileNearestRankError::InvalidPercentage { .. })
1548        ));
1549
1550        let params = PercentileNearestRankParams {
1551            length: Some(5),
1552            percentage: Some(-10.0),
1553        };
1554        let input = PercentileNearestRankInput::from_slice(&data, params);
1555        let result = percentile_nearest_rank_with_kernel(&input, kernel);
1556        assert!(matches!(
1557            result,
1558            Err(PercentileNearestRankError::InvalidPercentage { .. })
1559        ));
1560
1561        Ok(())
1562    }
1563
1564    fn check_pnr_reinput(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1565        skip_if_unsupported!(kernel, test_name);
1566
1567        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1568        let candles = read_candles_from_csv(file_path)?;
1569
1570        let first_params = PercentileNearestRankParams {
1571            length: Some(15),
1572            percentage: Some(50.0),
1573        };
1574        let first_input = PercentileNearestRankInput::from_candles(&candles, "close", first_params);
1575        let first_result = percentile_nearest_rank_with_kernel(&first_input, kernel)?;
1576
1577        let second_params = PercentileNearestRankParams {
1578            length: Some(15),
1579            percentage: Some(50.0),
1580        };
1581        let second_input =
1582            PercentileNearestRankInput::from_slice(&first_result.values, second_params);
1583        let second_result = percentile_nearest_rank_with_kernel(&second_input, kernel)?;
1584
1585        assert_eq!(second_result.values.len(), first_result.values.len());
1586        Ok(())
1587    }
1588
1589    fn check_pnr_nan_handling(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1590        skip_if_unsupported!(kernel, test_name);
1591
1592        let data = vec![
1593            1.0,
1594            2.0,
1595            f64::NAN,
1596            4.0,
1597            5.0,
1598            f64::NAN,
1599            7.0,
1600            8.0,
1601            9.0,
1602            10.0,
1603            11.0,
1604            12.0,
1605            13.0,
1606            f64::NAN,
1607            15.0,
1608        ];
1609
1610        let params = PercentileNearestRankParams {
1611            length: Some(5),
1612            percentage: Some(50.0),
1613        };
1614        let input = PercentileNearestRankInput::from_slice(&data, params);
1615        let result = percentile_nearest_rank_with_kernel(&input, kernel)?;
1616
1617        assert_eq!(result.values.len(), data.len());
1618
1619        assert!(!result.values[6].is_nan());
1620        Ok(())
1621    }
1622
1623    fn check_pnr_streaming(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1624        skip_if_unsupported!(kernel, test_name);
1625
1626        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1627        let candles = read_candles_from_csv(file_path)?;
1628
1629        let params = PercentileNearestRankParams {
1630            length: Some(15),
1631            percentage: Some(50.0),
1632        };
1633
1634        let input = PercentileNearestRankInput::from_candles(&candles, "close", params.clone());
1635        let batch_output = percentile_nearest_rank_with_kernel(&input, kernel)?.values;
1636
1637        let mut stream = PercentileNearestRankStream::try_new(params)?;
1638
1639        let mut stream_values = Vec::with_capacity(candles.close.len());
1640        for &price in &candles.close {
1641            match stream.update(price) {
1642                Some(val) => stream_values.push(val),
1643                None => stream_values.push(f64::NAN),
1644            }
1645        }
1646
1647        assert_eq!(batch_output.len(), stream_values.len());
1648        for (i, (&b, &s)) in batch_output.iter().zip(stream_values.iter()).enumerate() {
1649            if b.is_nan() && s.is_nan() {
1650                continue;
1651            }
1652            let diff = (b - s).abs();
1653            assert!(
1654                diff < 1e-9,
1655                "[{}] PNR streaming mismatch at idx {}: batch={}, stream={}, diff={}",
1656                test_name,
1657                i,
1658                b,
1659                s,
1660                diff
1661            );
1662        }
1663        Ok(())
1664    }
1665
1666    #[cfg(debug_assertions)]
1667    fn check_pnr_no_poison(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1668        skip_if_unsupported!(kernel, test_name);
1669
1670        let file_path = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1671        let candles = read_candles_from_csv(file_path)?;
1672
1673        let test_params = vec![
1674            PercentileNearestRankParams::default(),
1675            PercentileNearestRankParams {
1676                length: Some(5),
1677                percentage: Some(25.0),
1678            },
1679            PercentileNearestRankParams {
1680                length: Some(10),
1681                percentage: Some(75.0),
1682            },
1683            PercentileNearestRankParams {
1684                length: Some(20),
1685                percentage: Some(50.0),
1686            },
1687            PercentileNearestRankParams {
1688                length: Some(50),
1689                percentage: Some(90.0),
1690            },
1691        ];
1692
1693        for (param_idx, params) in test_params.iter().enumerate() {
1694            let input = PercentileNearestRankInput::from_candles(&candles, "close", params.clone());
1695            let output = percentile_nearest_rank_with_kernel(&input, kernel)?;
1696
1697            for (i, &val) in output.values.iter().enumerate() {
1698                if val.is_nan() {
1699                    continue;
1700                }
1701
1702                let bits = val.to_bits();
1703
1704                if bits == 0x11111111_11111111 {
1705                    panic!(
1706                        "[{}] Found alloc_with_nan_prefix poison value {} (0x{:016X}) at index {} \
1707                        with params: length={}, percentage={}",
1708                        test_name,
1709                        val,
1710                        bits,
1711                        i,
1712                        params.length.unwrap_or(15),
1713                        params.percentage.unwrap_or(50.0)
1714                    );
1715                }
1716            }
1717        }
1718
1719        Ok(())
1720    }
1721
1722    #[cfg(not(debug_assertions))]
1723    fn check_pnr_no_poison(_test_name: &str, _kernel: Kernel) -> Result<(), Box<dyn Error>> {
1724        Ok(())
1725    }
1726
1727    macro_rules! generate_all_pnr_tests {
1728        ($($test_fn:ident),*) => {
1729            paste::paste! {
1730                $(
1731                    #[test]
1732                    fn [<$test_fn _scalar_f64>]() {
1733                        let _ = $test_fn(stringify!([<$test_fn _scalar_f64>]), Kernel::Scalar);
1734                    }
1735                )*
1736                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1737                $(
1738                    #[test]
1739                    fn [<$test_fn _avx2_f64>]() {
1740                        let _ = $test_fn(stringify!([<$test_fn _avx2_f64>]), Kernel::Avx2);
1741                    }
1742                    #[test]
1743                    fn [<$test_fn _avx512_f64>]() {
1744                        let _ = $test_fn(stringify!([<$test_fn _avx512_f64>]), Kernel::Avx512);
1745                    }
1746                )*
1747                #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
1748                $(
1749                    #[test]
1750                    fn [<$test_fn _simd128_f64>]() {
1751                        let _ = $test_fn(stringify!([<$test_fn _simd128_f64>]), Kernel::Scalar);
1752                    }
1753                )*
1754            }
1755        }
1756    }
1757
1758    generate_all_pnr_tests!(
1759        check_pnr_accuracy,
1760        check_pnr_partial_params,
1761        check_pnr_default_candles,
1762        check_pnr_zero_period,
1763        check_pnr_period_exceeds_length,
1764        check_pnr_very_small_dataset,
1765        check_pnr_empty_input,
1766        check_pnr_invalid_percentage,
1767        check_pnr_reinput,
1768        check_pnr_nan_handling,
1769        check_pnr_streaming,
1770        check_pnr_no_poison
1771    );
1772
1773    #[test]
1774    fn test_percentile_nearest_rank_into_matches_api() {
1775        let mut data = Vec::with_capacity(256);
1776        data.extend_from_slice(&[f64::NAN, f64::NAN, f64::NAN]);
1777        for i in 0..253 {
1778            data.push((i as f64) * 0.5 + ((i % 7) as f64) * 0.1);
1779        }
1780
1781        let params = PercentileNearestRankParams {
1782            length: Some(15),
1783            percentage: Some(50.0),
1784        };
1785        let input = PercentileNearestRankInput::from_slice(&data, params);
1786
1787        let base = percentile_nearest_rank(&input).expect("baseline ok").values;
1788
1789        let mut out = vec![0.0; data.len()];
1790        let _ = percentile_nearest_rank_into(&input, &mut out).expect("into ok");
1791
1792        assert_eq!(base.len(), out.len());
1793        for (i, (&a, &b)) in base.iter().zip(out.iter()).enumerate() {
1794            let eq = (a.is_nan() && b.is_nan()) || (a == b);
1795            assert!(eq, "mismatch at {}: base={} out={}", i, a, b);
1796        }
1797    }
1798
1799    fn check_batch_default_row(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1800        skip_if_unsupported!(kernel, test_name);
1801
1802        let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1803        let c = read_candles_from_csv(file)?;
1804
1805        let output = PercentileNearestRankBatchBuilder::new()
1806            .kernel(kernel)
1807            .apply_candles(&c, "close")?;
1808
1809        let def = PercentileNearestRankParams::default();
1810        let row = output.values_for(&def).expect("default row missing");
1811
1812        assert_eq!(row.len(), c.close.len());
1813
1814        for i in 0..14 {
1815            assert!(row[i].is_nan());
1816        }
1817        assert!(!row[14].is_nan());
1818
1819        Ok(())
1820    }
1821
1822    fn check_batch_sweep(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1823        skip_if_unsupported!(kernel, test_name);
1824
1825        let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1826        let c = read_candles_from_csv(file)?;
1827
1828        let output = PercentileNearestRankBatchBuilder::new()
1829            .kernel(kernel)
1830            .period_range(10, 30, 10)
1831            .percentage_range(25.0, 75.0, 25.0)
1832            .apply_candles(&c, "close")?;
1833
1834        assert_eq!(output.rows, 9);
1835        assert_eq!(output.cols, c.close.len());
1836        assert_eq!(output.combos.len(), 9);
1837
1838        Ok(())
1839    }
1840
1841    #[cfg(debug_assertions)]
1842    fn check_batch_no_poison(test_name: &str, kernel: Kernel) -> Result<(), Box<dyn Error>> {
1843        skip_if_unsupported!(kernel, test_name);
1844
1845        let file = "src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv";
1846        let c = read_candles_from_csv(file)?;
1847
1848        let output = PercentileNearestRankBatchBuilder::new()
1849            .kernel(kernel)
1850            .period_range(5, 20, 5)
1851            .percentage_range(10.0, 90.0, 20.0)
1852            .apply_candles(&c, "close")?;
1853
1854        for &val in &output.values {
1855            if val.is_nan() {
1856                continue;
1857            }
1858            let bits = val.to_bits();
1859            if bits == 0x11111111_11111111
1860                || bits == 0x22222222_22222222
1861                || bits == 0x33333333_33333333
1862            {
1863                panic!(
1864                    "[{}] Found poison value {} (0x{:016X})",
1865                    test_name, val, bits
1866                );
1867            }
1868        }
1869
1870        Ok(())
1871    }
1872
1873    #[cfg(not(debug_assertions))]
1874    fn check_batch_no_poison(_test_name: &str, _kernel: Kernel) -> Result<(), Box<dyn Error>> {
1875        Ok(())
1876    }
1877
1878    macro_rules! gen_batch_tests {
1879        ($fn_name:ident) => {
1880            paste::paste! {
1881                #[test] fn [<$fn_name _scalar>]() {
1882                    let _ = $fn_name(stringify!([<$fn_name _scalar>]), Kernel::ScalarBatch);
1883                }
1884                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1885                #[test] fn [<$fn_name _avx2>]() {
1886                    let _ = $fn_name(stringify!([<$fn_name _avx2>]), Kernel::Avx2Batch);
1887                }
1888                #[cfg(all(feature = "nightly-avx", target_arch = "x86_64"))]
1889                #[test] fn [<$fn_name _avx512>]() {
1890                    let _ = $fn_name(stringify!([<$fn_name _avx512>]), Kernel::Avx512Batch);
1891                }
1892                #[test] fn [<$fn_name _auto_detect>]() {
1893                    let _ = $fn_name(stringify!([<$fn_name _auto_detect>]), Kernel::Auto);
1894                }
1895            }
1896        };
1897    }
1898
1899    gen_batch_tests!(check_batch_default_row);
1900    gen_batch_tests!(check_batch_sweep);
1901    gen_batch_tests!(check_batch_no_poison);
1902
1903    #[test]
1904    fn test_percentile_nearest_rank_basic() {
1905        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
1906        let params = PercentileNearestRankParams {
1907            length: Some(5),
1908            percentage: Some(50.0),
1909        };
1910        let input = PercentileNearestRankInput::from_slice(&data, params);
1911        let result = percentile_nearest_rank(&input).unwrap();
1912
1913        assert_eq!(result.values.len(), data.len());
1914
1915        for i in 0..4 {
1916            assert!(result.values[i].is_nan());
1917        }
1918
1919        assert_eq!(result.values[4], 3.0);
1920    }
1921
1922    #[test]
1923    fn test_percentile_nearest_rank_empty_data() {
1924        let data = vec![];
1925        let params = PercentileNearestRankParams::default();
1926        let input = PercentileNearestRankInput::from_slice(&data, params);
1927        let result = percentile_nearest_rank(&input);
1928
1929        assert!(matches!(
1930            result,
1931            Err(PercentileNearestRankError::EmptyInputData)
1932        ));
1933    }
1934
1935    #[test]
1936    fn test_percentile_nearest_rank_all_nan() {
1937        let data = vec![f64::NAN; 10];
1938        let params = PercentileNearestRankParams::default();
1939        let input = PercentileNearestRankInput::from_slice(&data, params);
1940        let result = percentile_nearest_rank(&input);
1941
1942        assert!(matches!(
1943            result,
1944            Err(PercentileNearestRankError::AllValuesNaN)
1945        ));
1946    }
1947
1948    #[test]
1949    fn test_percentile_nearest_rank_invalid_percentage() {
1950        let data = vec![1.0; 20];
1951        let params = PercentileNearestRankParams {
1952            length: Some(5),
1953            percentage: Some(150.0),
1954        };
1955        let input = PercentileNearestRankInput::from_slice(&data, params);
1956        let result = percentile_nearest_rank(&input);
1957
1958        assert!(matches!(
1959            result,
1960            Err(PercentileNearestRankError::InvalidPercentage { .. })
1961        ));
1962    }
1963
1964    #[test]
1965    fn test_percentile_nearest_rank_period_too_large() {
1966        let data = vec![1.0; 10];
1967        let params = PercentileNearestRankParams {
1968            length: Some(20),
1969            percentage: Some(50.0),
1970        };
1971        let input = PercentileNearestRankInput::from_slice(&data, params);
1972        let result = percentile_nearest_rank(&input);
1973
1974        assert!(matches!(
1975            result,
1976            Err(PercentileNearestRankError::InvalidPeriod { .. })
1977        ));
1978    }
1979
1980    #[test]
1981    fn test_percentile_nearest_rank_with_candles() {
1982        let close_data = vec![
1983            1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.5, 10.5, 11.5, 12.5, 13.5, 14.5, 15.5, 16.5,
1984            17.5, 18.5, 19.5, 20.5,
1985        ];
1986        let open_data = vec![1.0; 20];
1987        let high_data = vec![2.0; 20];
1988        let low_data = vec![0.5; 20];
1989        let volume_data = vec![100.0; 20];
1990
1991        let mut hl2 = Vec::with_capacity(20);
1992        let mut hlc3 = Vec::with_capacity(20);
1993        let mut ohlc4 = Vec::with_capacity(20);
1994        let mut hlcc4 = Vec::with_capacity(20);
1995
1996        for i in 0..20 {
1997            hl2.push((high_data[i] + low_data[i]) / 2.0);
1998            hlc3.push((high_data[i] + low_data[i] + close_data[i]) / 3.0);
1999            ohlc4.push((open_data[i] + high_data[i] + low_data[i] + close_data[i]) / 4.0);
2000            hlcc4.push((high_data[i] + low_data[i] + 2.0 * close_data[i]) / 4.0);
2001        }
2002
2003        let candles = Candles {
2004            timestamp: vec![0; 20],
2005            open: open_data,
2006            high: high_data,
2007            low: low_data,
2008            close: close_data,
2009            volume: volume_data,
2010            fields: CandleFieldFlags {
2011                open: true,
2012                high: true,
2013                low: true,
2014                close: true,
2015                volume: true,
2016            },
2017            hl2,
2018            hlc3,
2019            ohlc4,
2020            hlcc4,
2021        };
2022
2023        let params = PercentileNearestRankParams {
2024            length: Some(5),
2025            percentage: Some(50.0),
2026        };
2027        let input = PercentileNearestRankInput::from_candles(&candles, "close", params);
2028        let result = percentile_nearest_rank(&input).unwrap();
2029
2030        assert_eq!(result.values.len(), 20);
2031
2032        for i in 0..4 {
2033            assert!(result.values[i].is_nan());
2034        }
2035
2036        assert_eq!(result.values[4], 3.5);
2037    }
2038}
2039
2040#[cfg(feature = "python")]
2041pub fn register_percentile_nearest_rank_module(
2042    m: &Bound<'_, pyo3::types::PyModule>,
2043) -> PyResult<()> {
2044    m.add_function(wrap_pyfunction!(percentile_nearest_rank_py, m)?)?;
2045    m.add_function(wrap_pyfunction!(percentile_nearest_rank_batch_py, m)?)?;
2046    #[cfg(feature = "cuda")]
2047    {
2048        m.add_function(wrap_pyfunction!(
2049            percentile_nearest_rank_cuda_batch_dev_py,
2050            m
2051        )?)?;
2052        m.add_function(wrap_pyfunction!(
2053            percentile_nearest_rank_cuda_many_series_one_param_dev_py,
2054            m
2055        )?)?;
2056    }
2057    Ok(())
2058}
2059
2060#[cfg(all(feature = "python", feature = "cuda"))]
2061use crate::cuda::cuda_available;
2062#[cfg(all(feature = "python", feature = "cuda"))]
2063use crate::cuda::percentile_nearest_rank_wrapper::CudaPercentileNearestRank;
2064#[cfg(all(feature = "python", feature = "cuda"))]
2065use crate::indicators::moving_averages::alma::DeviceArrayF32Py;
2066
2067#[cfg(all(feature = "python", feature = "cuda"))]
2068#[pyfunction(name = "percentile_nearest_rank_cuda_batch_dev")]
2069#[pyo3(signature = (data_f32, length_range, percentage_range, device_id=0))]
2070pub fn percentile_nearest_rank_cuda_batch_dev_py<'py>(
2071    py: Python<'py>,
2072    data_f32: numpy::PyReadonlyArray1<'py, f32>,
2073    length_range: (usize, usize, usize),
2074    percentage_range: (f64, f64, f64),
2075    device_id: usize,
2076) -> PyResult<(DeviceArrayF32Py, Bound<'py, pyo3::types::PyDict>)> {
2077    use numpy::{IntoPyArray, PyArrayMethods};
2078    let slice_in = data_f32.as_slice()?;
2079    if !cuda_available() {
2080        return Err(PyValueError::new_err("CUDA not available"));
2081    }
2082    let sweep = PercentileNearestRankBatchRange {
2083        length: length_range,
2084        percentage: percentage_range,
2085    };
2086    let (inner, ctx, dev_id, combos) = py.allow_threads(|| {
2087        let cuda = CudaPercentileNearestRank::new(device_id)
2088            .map_err(|e| PyValueError::new_err(e.to_string()))?;
2089        let ctx = cuda.context_arc();
2090        let dev_id = cuda.device_id();
2091        cuda.pnr_batch_dev(slice_in, &sweep)
2092            .map(|(inner, combos)| (inner, ctx, dev_id, combos))
2093            .map_err(|e| PyValueError::new_err(e.to_string()))
2094    })?;
2095
2096    let dict = pyo3::types::PyDict::new(py);
2097    let lengths: Vec<u64> = combos
2098        .iter()
2099        .map(|c| c.length.unwrap_or(15) as u64)
2100        .collect();
2101    let percentages: Vec<f64> = combos
2102        .iter()
2103        .map(|c| c.percentage.unwrap_or(50.0))
2104        .collect();
2105    dict.set_item("lengths", lengths.into_pyarray(py))?;
2106    dict.set_item("percentages", percentages.into_pyarray(py))?;
2107    Ok((
2108        DeviceArrayF32Py {
2109            inner,
2110            _ctx: Some(ctx),
2111            device_id: Some(dev_id),
2112        },
2113        dict,
2114    ))
2115}
2116
2117#[cfg(all(feature = "python", feature = "cuda"))]
2118#[pyfunction(name = "percentile_nearest_rank_cuda_many_series_one_param_dev")]
2119#[pyo3(signature = (data_tm_f32, cols, rows, length, percentage, device_id=0))]
2120pub fn percentile_nearest_rank_cuda_many_series_one_param_dev_py<'py>(
2121    py: Python<'py>,
2122    data_tm_f32: numpy::PyReadonlyArray1<'py, f32>,
2123    cols: usize,
2124    rows: usize,
2125    length: usize,
2126    percentage: f64,
2127    device_id: usize,
2128) -> PyResult<DeviceArrayF32Py> {
2129    if !cuda_available() {
2130        return Err(PyValueError::new_err("CUDA not available"));
2131    }
2132    let slice_in = data_tm_f32.as_slice()?;
2133    let (inner, ctx, dev_id) = py.allow_threads(|| {
2134        let cuda = CudaPercentileNearestRank::new(device_id)
2135            .map_err(|e| PyValueError::new_err(e.to_string()))?;
2136        let ctx = cuda.context_arc();
2137        let dev_id = cuda.device_id();
2138        cuda.pnr_many_series_one_param_time_major_dev(slice_in, cols, rows, length, percentage)
2139            .map(|inner| (inner, ctx, dev_id))
2140            .map_err(|e| PyValueError::new_err(e.to_string()))
2141    })?;
2142    Ok(DeviceArrayF32Py {
2143        inner,
2144        _ctx: Some(ctx),
2145        device_id: Some(dev_id),
2146    })
2147}