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}