1#[cfg(feature = "python")]
2use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
3#[cfg(feature = "python")]
4use pyo3::exceptions::PyValueError;
5#[cfg(feature = "python")]
6use pyo3::prelude::*;
7#[cfg(feature = "python")]
8use pyo3::types::PyDict;
9
10#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
11use serde::{Deserialize, Serialize};
12#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
13use wasm_bindgen::prelude::*;
14
15use crate::utilities::data_loader::{source_type, Candles};
16use crate::utilities::enums::Kernel;
17use crate::utilities::helpers::{
18 alloc_with_nan_prefix, detect_best_batch_kernel, init_matrix_prefixes, make_uninit_matrix,
19};
20#[cfg(feature = "python")]
21use crate::utilities::kernel_validation::validate_kernel;
22
23#[cfg(not(target_arch = "wasm32"))]
24use rayon::prelude::*;
25use std::convert::AsRef;
26use std::mem::{ManuallyDrop, MaybeUninit};
27use thiserror::Error;
28
29impl<'a> AsRef<[f64]> for PsychologicalLineInput<'a> {
30 #[inline(always)]
31 fn as_ref(&self) -> &[f64] {
32 match &self.data {
33 PsychologicalLineData::Candles { candles, source } => source_type(candles, source),
34 PsychologicalLineData::Slice(slice) => slice,
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
40pub enum PsychologicalLineData<'a> {
41 Candles {
42 candles: &'a Candles,
43 source: &'a str,
44 },
45 Slice(&'a [f64]),
46}
47
48#[derive(Debug, Clone)]
49pub struct PsychologicalLineOutput {
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 PsychologicalLineParams {
59 pub length: Option<usize>,
60}
61
62impl Default for PsychologicalLineParams {
63 fn default() -> Self {
64 Self { length: Some(20) }
65 }
66}
67
68#[derive(Debug, Clone)]
69pub struct PsychologicalLineInput<'a> {
70 pub data: PsychologicalLineData<'a>,
71 pub params: PsychologicalLineParams,
72}
73
74impl<'a> PsychologicalLineInput<'a> {
75 #[inline]
76 pub fn from_candles(
77 candles: &'a Candles,
78 source: &'a str,
79 params: PsychologicalLineParams,
80 ) -> Self {
81 Self {
82 data: PsychologicalLineData::Candles { candles, source },
83 params,
84 }
85 }
86
87 #[inline]
88 pub fn from_slice(slice: &'a [f64], params: PsychologicalLineParams) -> Self {
89 Self {
90 data: PsychologicalLineData::Slice(slice),
91 params,
92 }
93 }
94
95 #[inline]
96 pub fn with_default_candles(candles: &'a Candles) -> Self {
97 Self::from_candles(candles, "close", PsychologicalLineParams::default())
98 }
99
100 #[inline]
101 pub fn get_length(&self) -> usize {
102 self.params.length.unwrap_or(20)
103 }
104}
105
106#[derive(Copy, Clone, Debug)]
107pub struct PsychologicalLineBuilder {
108 length: Option<usize>,
109 kernel: Kernel,
110}
111
112impl Default for PsychologicalLineBuilder {
113 fn default() -> Self {
114 Self {
115 length: None,
116 kernel: Kernel::Auto,
117 }
118 }
119}
120
121impl PsychologicalLineBuilder {
122 #[inline(always)]
123 pub fn new() -> Self {
124 Self::default()
125 }
126
127 #[inline(always)]
128 pub fn length(mut self, value: usize) -> Self {
129 self.length = Some(value);
130 self
131 }
132
133 #[inline(always)]
134 pub fn kernel(mut self, value: Kernel) -> Self {
135 self.kernel = value;
136 self
137 }
138
139 #[inline(always)]
140 pub fn apply(
141 self,
142 candles: &Candles,
143 ) -> Result<PsychologicalLineOutput, PsychologicalLineError> {
144 let input = PsychologicalLineInput::from_candles(
145 candles,
146 "close",
147 PsychologicalLineParams {
148 length: self.length,
149 },
150 );
151 psychological_line_with_kernel(&input, self.kernel)
152 }
153
154 #[inline(always)]
155 pub fn apply_slice(
156 self,
157 data: &[f64],
158 ) -> Result<PsychologicalLineOutput, PsychologicalLineError> {
159 let input = PsychologicalLineInput::from_slice(
160 data,
161 PsychologicalLineParams {
162 length: self.length,
163 },
164 );
165 psychological_line_with_kernel(&input, self.kernel)
166 }
167
168 #[inline(always)]
169 pub fn into_stream(self) -> Result<PsychologicalLineStream, PsychologicalLineError> {
170 PsychologicalLineStream::try_new(PsychologicalLineParams {
171 length: self.length,
172 })
173 }
174}
175
176#[derive(Debug, Error)]
177pub enum PsychologicalLineError {
178 #[error("psychological_line: Input data slice is empty.")]
179 EmptyInputData,
180 #[error("psychological_line: All values are NaN.")]
181 AllValuesNaN,
182 #[error("psychological_line: Invalid length: length = {length}, data length = {data_len}")]
183 InvalidLength { length: usize, data_len: usize },
184 #[error("psychological_line: Not enough valid data: needed = {needed}, valid = {valid}")]
185 NotEnoughValidData { needed: usize, valid: usize },
186 #[error("psychological_line: Output length mismatch: expected = {expected}, got = {got}")]
187 OutputLengthMismatch { expected: usize, got: usize },
188 #[error("psychological_line: Invalid range: start={start}, end={end}, step={step}")]
189 InvalidRange {
190 start: usize,
191 end: usize,
192 step: usize,
193 },
194 #[error("psychological_line: Invalid kernel for batch: {0:?}")]
195 InvalidKernelForBatch(Kernel),
196}
197
198#[inline(always)]
199fn first_valid_index(data: &[f64]) -> Option<usize> {
200 data.iter().position(|x| x.is_finite())
201}
202
203#[inline(always)]
204fn is_fast_path_clean(data: &[f64], first: usize) -> bool {
205 data[first..].iter().all(|x| x.is_finite())
206}
207
208#[inline(always)]
209fn psychological_line_prepare<'a>(
210 input: &'a PsychologicalLineInput,
211) -> Result<(&'a [f64], usize, usize), PsychologicalLineError> {
212 let data = input.as_ref();
213 let data_len = data.len();
214 if data_len == 0 {
215 return Err(PsychologicalLineError::EmptyInputData);
216 }
217
218 let first = first_valid_index(data).ok_or(PsychologicalLineError::AllValuesNaN)?;
219 let length = input.get_length();
220 if length == 0 || length > data_len {
221 return Err(PsychologicalLineError::InvalidLength { length, data_len });
222 }
223
224 let valid = data_len - first;
225 if valid <= length {
226 return Err(PsychologicalLineError::NotEnoughValidData {
227 needed: length + 1,
228 valid,
229 });
230 }
231
232 Ok((data, length, first))
233}
234
235#[inline(always)]
236fn psychological_line_compute_fast(data: &[f64], length: usize, first: usize, out: &mut [f64]) {
237 let warmup = first + length;
238 let scale = 100.0 / length as f64;
239 let mut count = 0usize;
240
241 for i in (first + 1)..=warmup {
242 count += usize::from(data[i] > data[i - 1]);
243 }
244 out[warmup] = count as f64 * scale;
245
246 for i in (warmup + 1)..data.len() {
247 count -= usize::from(data[i - length] > data[i - length - 1]);
248 count += usize::from(data[i] > data[i - 1]);
249 out[i] = count as f64 * scale;
250 }
251}
252
253#[inline(always)]
254fn psychological_line_compute_fallback(data: &[f64], length: usize, first: usize, out: &mut [f64]) {
255 let mut stream = PsychologicalLineStream::from_length(length);
256 for i in first..data.len() {
257 out[i] = stream.update_reset_on_nan(data[i]).unwrap_or(f64::NAN);
258 }
259}
260
261#[inline(always)]
262fn psychological_line_compute_into(
263 data: &[f64],
264 length: usize,
265 first: usize,
266 _kernel: Kernel,
267 out: &mut [f64],
268) {
269 if is_fast_path_clean(data, first) {
270 psychological_line_compute_fast(data, length, first, out);
271 } else {
272 psychological_line_compute_fallback(data, length, first, out);
273 }
274}
275
276#[inline]
277pub fn psychological_line(
278 input: &PsychologicalLineInput,
279) -> Result<PsychologicalLineOutput, PsychologicalLineError> {
280 psychological_line_with_kernel(input, Kernel::Auto)
281}
282
283pub fn psychological_line_with_kernel(
284 input: &PsychologicalLineInput,
285 kernel: Kernel,
286) -> Result<PsychologicalLineOutput, PsychologicalLineError> {
287 let (data, length, first) = psychological_line_prepare(input)?;
288 let warmup = first + length;
289 let mut out = alloc_with_nan_prefix(data.len(), warmup);
290 psychological_line_compute_into(data, length, first, kernel, &mut out);
291 Ok(PsychologicalLineOutput { values: out })
292}
293
294#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
295#[inline]
296pub fn psychological_line_into(
297 input: &PsychologicalLineInput,
298 out: &mut [f64],
299) -> Result<(), PsychologicalLineError> {
300 psychological_line_into_slice(out, input, Kernel::Auto)
301}
302
303pub fn psychological_line_into_slice(
304 out: &mut [f64],
305 input: &PsychologicalLineInput,
306 kernel: Kernel,
307) -> Result<(), PsychologicalLineError> {
308 let (data, length, first) = psychological_line_prepare(input)?;
309 if out.len() != data.len() {
310 return Err(PsychologicalLineError::OutputLengthMismatch {
311 expected: data.len(),
312 got: out.len(),
313 });
314 }
315
316 out.fill(f64::NAN);
317 psychological_line_compute_into(data, length, first, kernel, out);
318 Ok(())
319}
320
321#[derive(Clone, Debug)]
322pub struct PsychologicalLineStream {
323 length: usize,
324 prev: Option<f64>,
325 comparisons_seen: usize,
326 head: usize,
327 rolling_sum: usize,
328 buffer: Vec<u8>,
329}
330
331impl PsychologicalLineStream {
332 #[inline]
333 fn from_length(length: usize) -> Self {
334 Self {
335 length,
336 prev: None,
337 comparisons_seen: 0,
338 head: 0,
339 rolling_sum: 0,
340 buffer: vec![0; length.max(1)],
341 }
342 }
343
344 #[inline]
345 pub fn try_new(params: PsychologicalLineParams) -> Result<Self, PsychologicalLineError> {
346 let length = params.length.unwrap_or(20);
347 if length == 0 {
348 return Err(PsychologicalLineError::InvalidLength {
349 length,
350 data_len: 0,
351 });
352 }
353 Ok(Self::from_length(length))
354 }
355
356 #[inline(always)]
357 fn reset(&mut self) {
358 self.prev = None;
359 self.comparisons_seen = 0;
360 self.head = 0;
361 self.rolling_sum = 0;
362 self.buffer.fill(0);
363 }
364
365 #[inline(always)]
366 pub fn update(&mut self, value: f64) -> Option<f64> {
367 if !value.is_finite() {
368 return None;
369 }
370
371 let prev = match self.prev.replace(value) {
372 Some(prev) => prev,
373 None => return None,
374 };
375
376 let up = u8::from(value > prev);
377 if self.comparisons_seen < self.length {
378 self.buffer[self.comparisons_seen] = up;
379 self.rolling_sum += up as usize;
380 self.comparisons_seen += 1;
381 if self.comparisons_seen < self.length {
382 return None;
383 }
384 return Some(self.rolling_sum as f64 * (100.0 / self.length as f64));
385 }
386
387 let old = self.buffer[self.head] as usize;
388 self.buffer[self.head] = up;
389 self.rolling_sum = self.rolling_sum + up as usize - old;
390 self.head += 1;
391 if self.head == self.length {
392 self.head = 0;
393 }
394
395 Some(self.rolling_sum as f64 * (100.0 / self.length as f64))
396 }
397
398 #[inline(always)]
399 pub fn update_reset_on_nan(&mut self, value: f64) -> Option<f64> {
400 if !value.is_finite() {
401 self.reset();
402 return None;
403 }
404 self.update(value)
405 }
406}
407
408#[derive(Clone, Debug)]
409pub struct PsychologicalLineBatchRange {
410 pub length: (usize, usize, usize),
411}
412
413impl Default for PsychologicalLineBatchRange {
414 fn default() -> Self {
415 Self {
416 length: (20, 200, 1),
417 }
418 }
419}
420
421#[derive(Clone, Debug, Default)]
422pub struct PsychologicalLineBatchBuilder {
423 range: PsychologicalLineBatchRange,
424 kernel: Kernel,
425}
426
427impl PsychologicalLineBatchBuilder {
428 pub fn new() -> Self {
429 Self::default()
430 }
431
432 pub fn kernel(mut self, kernel: Kernel) -> Self {
433 self.kernel = kernel;
434 self
435 }
436
437 #[inline]
438 pub fn length_range(mut self, start: usize, end: usize, step: usize) -> Self {
439 self.range.length = (start, end, step);
440 self
441 }
442
443 #[inline]
444 pub fn length_static(mut self, length: usize) -> Self {
445 self.range.length = (length, length, 0);
446 self
447 }
448
449 pub fn apply_slice(
450 self,
451 data: &[f64],
452 ) -> Result<PsychologicalLineBatchOutput, PsychologicalLineError> {
453 psychological_line_batch_with_kernel(data, &self.range, self.kernel)
454 }
455
456 pub fn apply_candles(
457 self,
458 candles: &Candles,
459 source: &str,
460 ) -> Result<PsychologicalLineBatchOutput, PsychologicalLineError> {
461 self.apply_slice(source_type(candles, source))
462 }
463}
464
465#[derive(Clone, Debug)]
466pub struct PsychologicalLineBatchOutput {
467 pub values: Vec<f64>,
468 pub combos: Vec<PsychologicalLineParams>,
469 pub rows: usize,
470 pub cols: usize,
471}
472
473impl PsychologicalLineBatchOutput {
474 pub fn row_for_params(&self, params: &PsychologicalLineParams) -> Option<usize> {
475 self.combos
476 .iter()
477 .position(|combo| combo.length.unwrap_or(20) == params.length.unwrap_or(20))
478 }
479
480 pub fn values_for(&self, params: &PsychologicalLineParams) -> Option<&[f64]> {
481 self.row_for_params(params).map(|row| {
482 let start = row * self.cols;
483 &self.values[start..start + self.cols]
484 })
485 }
486}
487
488fn axis_usize(range: (usize, usize, usize)) -> Result<Vec<usize>, PsychologicalLineError> {
489 let (start, end, step) = range;
490 if step == 0 || start == end {
491 return Ok(vec![start]);
492 }
493
494 let mut out = Vec::new();
495 if start < end {
496 let mut value = start;
497 while value <= end {
498 out.push(value);
499 match value.checked_add(step) {
500 Some(next) if next > value => value = next,
501 _ => break,
502 }
503 }
504 } else {
505 let mut value = start;
506 while value >= end {
507 out.push(value);
508 if value < end + step {
509 break;
510 }
511 value = value.saturating_sub(step);
512 if value == 0 {
513 break;
514 }
515 }
516 }
517
518 if out.is_empty() {
519 return Err(PsychologicalLineError::InvalidRange { start, end, step });
520 }
521 Ok(out)
522}
523
524pub fn expand_grid_psychological_line(
525 sweep: &PsychologicalLineBatchRange,
526) -> Result<Vec<PsychologicalLineParams>, PsychologicalLineError> {
527 Ok(axis_usize(sweep.length)?
528 .into_iter()
529 .map(|length| PsychologicalLineParams {
530 length: Some(length),
531 })
532 .collect())
533}
534
535pub fn psychological_line_batch_with_kernel(
536 data: &[f64],
537 sweep: &PsychologicalLineBatchRange,
538 kernel: Kernel,
539) -> Result<PsychologicalLineBatchOutput, PsychologicalLineError> {
540 let batch_kernel = match kernel {
541 Kernel::Auto => Kernel::ScalarBatch,
542 other if other.is_batch() => other,
543 other => return Err(PsychologicalLineError::InvalidKernelForBatch(other)),
544 };
545 psychological_line_batch_impl(data, sweep, batch_kernel.to_non_batch(), true)
546}
547
548pub fn psychological_line_batch_slice(
549 data: &[f64],
550 sweep: &PsychologicalLineBatchRange,
551) -> Result<PsychologicalLineBatchOutput, PsychologicalLineError> {
552 psychological_line_batch_impl(data, sweep, Kernel::Scalar, false)
553}
554
555pub fn psychological_line_batch_par_slice(
556 data: &[f64],
557 sweep: &PsychologicalLineBatchRange,
558) -> Result<PsychologicalLineBatchOutput, PsychologicalLineError> {
559 psychological_line_batch_impl(data, sweep, Kernel::Scalar, true)
560}
561
562fn psychological_line_batch_impl(
563 data: &[f64],
564 sweep: &PsychologicalLineBatchRange,
565 kernel: Kernel,
566 parallel: bool,
567) -> Result<PsychologicalLineBatchOutput, PsychologicalLineError> {
568 let combos = expand_grid_psychological_line(sweep)?;
569 let rows = combos.len();
570 let cols = data.len();
571
572 if cols == 0 {
573 return Err(PsychologicalLineError::EmptyInputData);
574 }
575
576 let first = first_valid_index(data).ok_or(PsychologicalLineError::AllValuesNaN)?;
577 let max_length = combos
578 .iter()
579 .map(|params| params.length.unwrap_or(20))
580 .max()
581 .unwrap_or(20);
582 let valid = cols - first;
583 if valid <= max_length {
584 return Err(PsychologicalLineError::NotEnoughValidData {
585 needed: max_length + 1,
586 valid,
587 });
588 }
589
590 let mut matrix = make_uninit_matrix(rows, cols);
591 let warmups: Vec<usize> = combos
592 .iter()
593 .map(|params| first + params.length.unwrap_or(20))
594 .collect();
595 init_matrix_prefixes(&mut matrix, cols, &warmups);
596
597 let mut guard = ManuallyDrop::new(matrix);
598 let out_mu: &mut [MaybeUninit<f64>] =
599 unsafe { std::slice::from_raw_parts_mut(guard.as_mut_ptr(), guard.len()) };
600
601 let do_row = |row: usize, row_mu: &mut [MaybeUninit<f64>]| {
602 let length = combos[row].length.unwrap_or(20);
603 let dst = unsafe {
604 std::slice::from_raw_parts_mut(row_mu.as_mut_ptr() as *mut f64, row_mu.len())
605 };
606 psychological_line_compute_into(data, length, first, kernel, dst);
607 };
608
609 if parallel {
610 #[cfg(not(target_arch = "wasm32"))]
611 out_mu
612 .par_chunks_mut(cols)
613 .enumerate()
614 .for_each(|(row, row_mu)| do_row(row, row_mu));
615 #[cfg(target_arch = "wasm32")]
616 for (row, row_mu) in out_mu.chunks_mut(cols).enumerate() {
617 do_row(row, row_mu);
618 }
619 } else {
620 for (row, row_mu) in out_mu.chunks_mut(cols).enumerate() {
621 do_row(row, row_mu);
622 }
623 }
624
625 let values = unsafe {
626 Vec::from_raw_parts(
627 guard.as_mut_ptr() as *mut f64,
628 guard.len(),
629 guard.capacity(),
630 )
631 };
632
633 Ok(PsychologicalLineBatchOutput {
634 values,
635 combos,
636 rows,
637 cols,
638 })
639}
640
641fn psychological_line_batch_inner_into(
642 data: &[f64],
643 sweep: &PsychologicalLineBatchRange,
644 kernel: Kernel,
645 parallel: bool,
646 out: &mut [f64],
647) -> Result<(), PsychologicalLineError> {
648 let combos = expand_grid_psychological_line(sweep)?;
649 let rows = combos.len();
650 let cols = data.len();
651 if rows.checked_mul(cols) != Some(out.len()) {
652 return Err(PsychologicalLineError::OutputLengthMismatch {
653 expected: rows * cols,
654 got: out.len(),
655 });
656 }
657
658 let first = first_valid_index(data).ok_or(PsychologicalLineError::AllValuesNaN)?;
659 for (row, params) in combos.iter().enumerate() {
660 let length = params.length.unwrap_or(20);
661 let row_out = &mut out[row * cols..(row + 1) * cols];
662 row_out.fill(f64::NAN);
663 if cols - first <= length {
664 return Err(PsychologicalLineError::NotEnoughValidData {
665 needed: length + 1,
666 valid: cols - first,
667 });
668 }
669 }
670
671 let do_row = |row: usize, row_out: &mut [f64]| {
672 let length = combos[row].length.unwrap_or(20);
673 psychological_line_compute_into(data, length, first, kernel, row_out);
674 };
675
676 if parallel {
677 #[cfg(not(target_arch = "wasm32"))]
678 out.par_chunks_mut(cols)
679 .enumerate()
680 .for_each(|(row, row_out)| do_row(row, row_out));
681 #[cfg(target_arch = "wasm32")]
682 for (row, row_out) in out.chunks_mut(cols).enumerate() {
683 do_row(row, row_out);
684 }
685 } else {
686 for (row, row_out) in out.chunks_mut(cols).enumerate() {
687 do_row(row, row_out);
688 }
689 }
690
691 Ok(())
692}
693
694#[cfg(feature = "python")]
695#[pyfunction(name = "psychological_line")]
696#[pyo3(signature = (data, length=20, kernel=None))]
697pub fn psychological_line_py<'py>(
698 py: Python<'py>,
699 data: PyReadonlyArray1<'py, f64>,
700 length: usize,
701 kernel: Option<&str>,
702) -> PyResult<Bound<'py, PyArray1<f64>>> {
703 let data = data.as_slice()?;
704 let kernel = validate_kernel(kernel, false)?;
705 let input = PsychologicalLineInput::from_slice(
706 data,
707 PsychologicalLineParams {
708 length: Some(length),
709 },
710 );
711 let output = py
712 .allow_threads(|| psychological_line_with_kernel(&input, kernel))
713 .map_err(|e| PyValueError::new_err(e.to_string()))?;
714 Ok(output.values.into_pyarray(py))
715}
716
717#[cfg(feature = "python")]
718#[pyclass(name = "PsychologicalLineStream")]
719pub struct PsychologicalLineStreamPy {
720 stream: PsychologicalLineStream,
721}
722
723#[cfg(feature = "python")]
724#[pymethods]
725impl PsychologicalLineStreamPy {
726 #[new]
727 #[pyo3(signature = (length=20))]
728 fn new(length: usize) -> PyResult<Self> {
729 let stream = PsychologicalLineStream::try_new(PsychologicalLineParams {
730 length: Some(length),
731 })
732 .map_err(|e| PyValueError::new_err(e.to_string()))?;
733 Ok(Self { stream })
734 }
735
736 fn update(&mut self, value: f64) -> Option<f64> {
737 self.stream.update_reset_on_nan(value)
738 }
739}
740
741#[cfg(feature = "python")]
742#[pyfunction(name = "psychological_line_batch")]
743#[pyo3(signature = (data, length_range, kernel=None))]
744pub fn psychological_line_batch_py<'py>(
745 py: Python<'py>,
746 data: PyReadonlyArray1<'py, f64>,
747 length_range: (usize, usize, usize),
748 kernel: Option<&str>,
749) -> PyResult<Bound<'py, PyDict>> {
750 let data = data.as_slice()?;
751 let sweep = PsychologicalLineBatchRange {
752 length: length_range,
753 };
754 let combos =
755 expand_grid_psychological_line(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
756 let rows = combos.len();
757 let cols = data.len();
758 let total = rows
759 .checked_mul(cols)
760 .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
761 let arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
762 let out = unsafe { arr.as_slice_mut()? };
763 let kernel = validate_kernel(kernel, true)?;
764
765 py.allow_threads(|| {
766 let batch_kernel = match kernel {
767 Kernel::Auto => detect_best_batch_kernel(),
768 other => other,
769 };
770 psychological_line_batch_inner_into(data, &sweep, batch_kernel.to_non_batch(), true, out)
771 })
772 .map_err(|e| PyValueError::new_err(e.to_string()))?;
773
774 let dict = PyDict::new(py);
775 dict.set_item("values", arr.reshape((rows, cols))?)?;
776 dict.set_item(
777 "lengths",
778 combos
779 .iter()
780 .map(|params| params.length.unwrap_or(20) as u64)
781 .collect::<Vec<_>>()
782 .into_pyarray(py),
783 )?;
784 dict.set_item("rows", rows)?;
785 dict.set_item("cols", cols)?;
786 Ok(dict)
787}
788
789#[cfg(feature = "python")]
790pub fn register_psychological_line_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
791 m.add_function(wrap_pyfunction!(psychological_line_py, m)?)?;
792 m.add_function(wrap_pyfunction!(psychological_line_batch_py, m)?)?;
793 m.add_class::<PsychologicalLineStreamPy>()?;
794 Ok(())
795}
796
797#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
798#[derive(Debug, Clone, Serialize, Deserialize)]
799struct PsychologicalLineBatchConfig {
800 length_range: Vec<usize>,
801}
802
803#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
804#[derive(Debug, Clone, Serialize, Deserialize)]
805struct PsychologicalLineBatchJsOutput {
806 values: Vec<f64>,
807 rows: usize,
808 cols: usize,
809 combos: Vec<PsychologicalLineParams>,
810}
811
812#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
813#[wasm_bindgen(js_name = "psychological_line_js")]
814pub fn psychological_line_js(data: &[f64], length: usize) -> Result<Vec<f64>, JsValue> {
815 let input = PsychologicalLineInput::from_slice(
816 data,
817 PsychologicalLineParams {
818 length: Some(length),
819 },
820 );
821 let mut out = vec![0.0; data.len()];
822 psychological_line_into_slice(&mut out, &input, Kernel::Auto)
823 .map_err(|e| JsValue::from_str(&e.to_string()))?;
824 Ok(out)
825}
826
827#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
828#[wasm_bindgen(js_name = "psychological_line_batch_js")]
829pub fn psychological_line_batch_js(data: &[f64], config: JsValue) -> Result<JsValue, JsValue> {
830 let config: PsychologicalLineBatchConfig = serde_wasm_bindgen::from_value(config)
831 .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
832 if config.length_range.len() != 3 {
833 return Err(JsValue::from_str(
834 "Invalid config: length_range must have exactly 3 elements [start, end, step]",
835 ));
836 }
837 let sweep = PsychologicalLineBatchRange {
838 length: (
839 config.length_range[0],
840 config.length_range[1],
841 config.length_range[2],
842 ),
843 };
844 let batch = psychological_line_batch_slice(data, &sweep)
845 .map_err(|e| JsValue::from_str(&e.to_string()))?;
846 serde_wasm_bindgen::to_value(&PsychologicalLineBatchJsOutput {
847 values: batch.values,
848 rows: batch.rows,
849 cols: batch.cols,
850 combos: batch.combos,
851 })
852 .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
853}
854
855#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
856#[wasm_bindgen]
857pub fn psychological_line_alloc(len: usize) -> *mut f64 {
858 let mut vec = Vec::<f64>::with_capacity(len);
859 let ptr = vec.as_mut_ptr();
860 std::mem::forget(vec);
861 ptr
862}
863
864#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
865#[wasm_bindgen]
866pub fn psychological_line_free(ptr: *mut f64, len: usize) {
867 unsafe {
868 let _ = Vec::from_raw_parts(ptr, len, len);
869 }
870}
871
872#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
873#[wasm_bindgen]
874pub fn psychological_line_into(
875 in_ptr: *const f64,
876 out_ptr: *mut f64,
877 len: usize,
878 length: usize,
879) -> Result<(), JsValue> {
880 if in_ptr.is_null() || out_ptr.is_null() {
881 return Err(JsValue::from_str(
882 "null pointer passed to psychological_line_into",
883 ));
884 }
885 unsafe {
886 let data = std::slice::from_raw_parts(in_ptr, len);
887 let out = std::slice::from_raw_parts_mut(out_ptr, len);
888 let input = PsychologicalLineInput::from_slice(
889 data,
890 PsychologicalLineParams {
891 length: Some(length),
892 },
893 );
894 psychological_line_into_slice(out, &input, Kernel::Auto)
895 .map_err(|e| JsValue::from_str(&e.to_string()))
896 }
897}
898
899#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
900#[wasm_bindgen(js_name = "psychological_line_into_host")]
901pub fn psychological_line_into_host(
902 data: &[f64],
903 out_ptr: *mut f64,
904 length: usize,
905) -> Result<(), JsValue> {
906 if out_ptr.is_null() {
907 return Err(JsValue::from_str(
908 "null pointer passed to psychological_line_into_host",
909 ));
910 }
911 unsafe {
912 let out = std::slice::from_raw_parts_mut(out_ptr, data.len());
913 let input = PsychologicalLineInput::from_slice(
914 data,
915 PsychologicalLineParams {
916 length: Some(length),
917 },
918 );
919 psychological_line_into_slice(out, &input, Kernel::Auto)
920 .map_err(|e| JsValue::from_str(&e.to_string()))
921 }
922}
923
924#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
925#[wasm_bindgen]
926pub fn psychological_line_batch_into(
927 in_ptr: *const f64,
928 out_ptr: *mut f64,
929 len: usize,
930 length_start: usize,
931 length_end: usize,
932 length_step: usize,
933) -> Result<usize, JsValue> {
934 if in_ptr.is_null() || out_ptr.is_null() {
935 return Err(JsValue::from_str(
936 "null pointer passed to psychological_line_batch_into",
937 ));
938 }
939 unsafe {
940 let data = std::slice::from_raw_parts(in_ptr, len);
941 let sweep = PsychologicalLineBatchRange {
942 length: (length_start, length_end, length_step),
943 };
944 let combos = expand_grid_psychological_line(&sweep)
945 .map_err(|e| JsValue::from_str(&e.to_string()))?;
946 let rows = combos.len();
947 let out = std::slice::from_raw_parts_mut(out_ptr, rows * len);
948 psychological_line_batch_inner_into(data, &sweep, Kernel::Scalar, false, out)
949 .map_err(|e| JsValue::from_str(&e.to_string()))?;
950 Ok(rows)
951 }
952}
953
954#[cfg(test)]
955mod tests {
956 use super::*;
957 use crate::indicators::dispatch::{
958 compute_cpu_batch, IndicatorBatchRequest, IndicatorDataRef, IndicatorParamSet, ParamKV,
959 ParamValue,
960 };
961
962 fn sample_data(len: usize) -> Vec<f64> {
963 let mut out = Vec::with_capacity(len);
964 for i in 0..len {
965 out.push(100.0 + i as f64 * 0.1 + (i as f64 * 0.37).sin() * 2.0);
966 }
967 out
968 }
969
970 fn naive_psy(data: &[f64], length: usize) -> Vec<f64> {
971 let mut out = vec![f64::NAN; data.len()];
972 if data.len() <= length {
973 return out;
974 }
975 let scale = 100.0 / length as f64;
976 let mut count = 0usize;
977 for i in 1..=length {
978 count += usize::from(data[i] > data[i - 1]);
979 }
980 out[length] = count as f64 * scale;
981 for i in (length + 1)..data.len() {
982 count -= usize::from(data[i - length] > data[i - length - 1]);
983 count += usize::from(data[i] > data[i - 1]);
984 out[i] = count as f64 * scale;
985 }
986 out
987 }
988
989 fn assert_close(a: &[f64], b: &[f64]) {
990 assert_eq!(a.len(), b.len());
991 for i in 0..a.len() {
992 if a[i].is_nan() || b[i].is_nan() {
993 assert!(
994 a[i].is_nan() && b[i].is_nan(),
995 "nan mismatch at {i}: {} vs {}",
996 a[i],
997 b[i]
998 );
999 } else {
1000 assert!(
1001 (a[i] - b[i]).abs() <= 1e-10,
1002 "mismatch at {i}: {} vs {}",
1003 a[i],
1004 b[i]
1005 );
1006 }
1007 }
1008 }
1009
1010 #[test]
1011 fn psychological_line_matches_naive() {
1012 let data = sample_data(256);
1013 let input =
1014 PsychologicalLineInput::from_slice(&data, PsychologicalLineParams { length: Some(20) });
1015 let out = psychological_line(&input).expect("indicator");
1016 let reference = naive_psy(&data, 20);
1017 assert_close(&out.values, &reference);
1018 }
1019
1020 #[test]
1021 fn psychological_line_into_matches_api() {
1022 let data = sample_data(192);
1023 let input =
1024 PsychologicalLineInput::from_slice(&data, PsychologicalLineParams { length: Some(14) });
1025 let baseline = psychological_line(&input).expect("baseline");
1026 let mut out = vec![0.0; data.len()];
1027 psychological_line_into(&input, &mut out).expect("into");
1028 assert_close(&baseline.values, &out);
1029 }
1030
1031 #[test]
1032 fn psychological_line_stream_matches_batch() {
1033 let data = sample_data(192);
1034 let batch = psychological_line(&PsychologicalLineInput::from_slice(
1035 &data,
1036 PsychologicalLineParams { length: Some(20) },
1037 ))
1038 .expect("batch");
1039 let mut stream =
1040 PsychologicalLineStream::try_new(PsychologicalLineParams { length: Some(20) })
1041 .expect("stream");
1042 let mut values = Vec::with_capacity(data.len());
1043 for &value in &data {
1044 values.push(stream.update(value).unwrap_or(f64::NAN));
1045 }
1046 assert_close(&batch.values, &values);
1047 }
1048
1049 #[test]
1050 fn psychological_line_batch_single_param_matches_single() {
1051 let data = sample_data(192);
1052 let sweep = PsychologicalLineBatchRange {
1053 length: (20, 20, 0),
1054 };
1055 let batch = psychological_line_batch_with_kernel(&data, &sweep, Kernel::ScalarBatch)
1056 .expect("batch");
1057 let single = psychological_line(&PsychologicalLineInput::from_slice(
1058 &data,
1059 PsychologicalLineParams { length: Some(20) },
1060 ))
1061 .expect("single");
1062 assert_eq!(batch.rows, 1);
1063 assert_eq!(batch.cols, data.len());
1064 assert_close(&batch.values, &single.values);
1065 }
1066
1067 #[test]
1068 fn psychological_line_rejects_invalid_length() {
1069 let data = sample_data(32);
1070 let err = psychological_line(&PsychologicalLineInput::from_slice(
1071 &data,
1072 PsychologicalLineParams { length: Some(0) },
1073 ))
1074 .expect_err("invalid length");
1075 assert!(matches!(err, PsychologicalLineError::InvalidLength { .. }));
1076 }
1077
1078 #[test]
1079 fn psychological_line_dispatch_matches_direct() {
1080 let data = sample_data(192);
1081 let params = [ParamKV {
1082 key: "length",
1083 value: ParamValue::Int(20),
1084 }];
1085 let combos = [IndicatorParamSet { params: ¶ms }];
1086 let out = compute_cpu_batch(IndicatorBatchRequest {
1087 indicator_id: "psychological_line",
1088 output_id: Some("value"),
1089 data: IndicatorDataRef::Slice { values: &data },
1090 combos: &combos,
1091 kernel: Kernel::ScalarBatch,
1092 })
1093 .expect("dispatch");
1094 let direct = psychological_line(&PsychologicalLineInput::from_slice(
1095 &data,
1096 PsychologicalLineParams { length: Some(20) },
1097 ))
1098 .expect("direct");
1099 assert_eq!(out.rows, 1);
1100 assert_eq!(out.cols, data.len());
1101 assert_close(out.values_f64.as_ref().expect("values"), &direct.values);
1102 }
1103}