Skip to main content

vector_ta/indicators/
psychological_line.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#[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: &params }];
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}