Skip to main content

vector_ta/indicators/
projection_oscillator.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, detect_best_kernel, init_matrix_prefixes,
19    make_uninit_matrix,
20};
21#[cfg(feature = "python")]
22use crate::utilities::kernel_validation::validate_kernel;
23#[cfg(not(target_arch = "wasm32"))]
24use rayon::prelude::*;
25use std::collections::VecDeque;
26use std::error::Error;
27use thiserror::Error;
28
29#[derive(Debug, Clone)]
30pub enum ProjectionOscillatorData<'a> {
31    Candles {
32        candles: &'a Candles,
33        source: &'a str,
34    },
35    Slices {
36        high: &'a [f64],
37        low: &'a [f64],
38        source: &'a [f64],
39    },
40}
41
42#[derive(Debug, Clone)]
43pub struct ProjectionOscillatorOutput {
44    pub pbo: Vec<f64>,
45    pub signal: Vec<f64>,
46}
47
48#[derive(Debug, Clone)]
49#[cfg_attr(
50    all(target_arch = "wasm32", feature = "wasm"),
51    derive(Serialize, Deserialize)
52)]
53pub struct ProjectionOscillatorParams {
54    pub length: Option<usize>,
55    pub smooth_length: Option<usize>,
56}
57
58impl Default for ProjectionOscillatorParams {
59    fn default() -> Self {
60        Self {
61            length: Some(14),
62            smooth_length: Some(4),
63        }
64    }
65}
66
67#[derive(Debug, Clone)]
68pub struct ProjectionOscillatorInput<'a> {
69    pub data: ProjectionOscillatorData<'a>,
70    pub params: ProjectionOscillatorParams,
71}
72
73impl<'a> ProjectionOscillatorInput<'a> {
74    #[inline]
75    pub fn from_candles(
76        candles: &'a Candles,
77        source: &'a str,
78        params: ProjectionOscillatorParams,
79    ) -> Self {
80        Self {
81            data: ProjectionOscillatorData::Candles { candles, source },
82            params,
83        }
84    }
85
86    #[inline]
87    pub fn from_slices(
88        high: &'a [f64],
89        low: &'a [f64],
90        source: &'a [f64],
91        params: ProjectionOscillatorParams,
92    ) -> Self {
93        Self {
94            data: ProjectionOscillatorData::Slices { high, low, source },
95            params,
96        }
97    }
98
99    #[inline]
100    pub fn with_default_candles(candles: &'a Candles) -> Self {
101        Self::from_candles(candles, "close", ProjectionOscillatorParams::default())
102    }
103
104    #[inline]
105    pub fn get_length(&self) -> usize {
106        self.params.length.unwrap_or(14)
107    }
108
109    #[inline]
110    pub fn get_smooth_length(&self) -> usize {
111        self.params.smooth_length.unwrap_or(4)
112    }
113}
114
115#[derive(Copy, Clone, Debug)]
116pub struct ProjectionOscillatorBuilder {
117    length: Option<usize>,
118    smooth_length: Option<usize>,
119    kernel: Kernel,
120}
121
122impl Default for ProjectionOscillatorBuilder {
123    fn default() -> Self {
124        Self {
125            length: None,
126            smooth_length: None,
127            kernel: Kernel::Auto,
128        }
129    }
130}
131
132impl ProjectionOscillatorBuilder {
133    #[inline(always)]
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    #[inline(always)]
139    pub fn length(mut self, value: usize) -> Self {
140        self.length = Some(value);
141        self
142    }
143
144    #[inline(always)]
145    pub fn smooth_length(mut self, value: usize) -> Self {
146        self.smooth_length = Some(value);
147        self
148    }
149
150    #[inline(always)]
151    pub fn kernel(mut self, value: Kernel) -> Self {
152        self.kernel = value;
153        self
154    }
155
156    #[inline(always)]
157    pub fn apply(
158        self,
159        candles: &Candles,
160    ) -> Result<ProjectionOscillatorOutput, ProjectionOscillatorError> {
161        let params = ProjectionOscillatorParams {
162            length: self.length,
163            smooth_length: self.smooth_length,
164        };
165        projection_oscillator_with_kernel(
166            &ProjectionOscillatorInput::from_candles(candles, "close", params),
167            self.kernel,
168        )
169    }
170
171    #[inline(always)]
172    pub fn apply_slices(
173        self,
174        high: &[f64],
175        low: &[f64],
176        source: &[f64],
177    ) -> Result<ProjectionOscillatorOutput, ProjectionOscillatorError> {
178        let params = ProjectionOscillatorParams {
179            length: self.length,
180            smooth_length: self.smooth_length,
181        };
182        projection_oscillator_with_kernel(
183            &ProjectionOscillatorInput::from_slices(high, low, source, params),
184            self.kernel,
185        )
186    }
187
188    #[inline(always)]
189    pub fn into_stream(self) -> Result<ProjectionOscillatorStream, ProjectionOscillatorError> {
190        ProjectionOscillatorStream::try_new(ProjectionOscillatorParams {
191            length: self.length,
192            smooth_length: self.smooth_length,
193        })
194    }
195}
196
197#[derive(Debug, Error)]
198pub enum ProjectionOscillatorError {
199    #[error("projection_oscillator: Input data slice is empty.")]
200    EmptyInputData,
201    #[error(
202        "projection_oscillator: Input length mismatch: high = {high_len}, low = {low_len}, source = {source_len}"
203    )]
204    InputLengthMismatch {
205        high_len: usize,
206        low_len: usize,
207        source_len: usize,
208    },
209    #[error("projection_oscillator: All values are NaN.")]
210    AllValuesNaN,
211    #[error("projection_oscillator: Invalid length: {length}")]
212    InvalidLength { length: usize },
213    #[error("projection_oscillator: Invalid smooth_length: {smooth_length}")]
214    InvalidSmoothLength { smooth_length: usize },
215    #[error("projection_oscillator: Not enough valid data: needed = {needed}, valid = {valid}")]
216    NotEnoughValidData { needed: usize, valid: usize },
217    #[error("projection_oscillator: Output length mismatch: expected = {expected}, got = {got}")]
218    OutputLengthMismatch { expected: usize, got: usize },
219    #[error("projection_oscillator: Invalid range: start={start}, end={end}, step={step}")]
220    InvalidRange {
221        start: usize,
222        end: usize,
223        step: usize,
224    },
225    #[error("projection_oscillator: Invalid kernel for batch: {0:?}")]
226    InvalidKernelForBatch(Kernel),
227    #[error(
228        "projection_oscillator: Output length mismatch: dst = {dst_len}, expected = {expected_len}"
229    )]
230    MismatchedOutputLen { dst_len: usize, expected_len: usize },
231    #[error("projection_oscillator: Invalid input: {msg}")]
232    InvalidInput { msg: String },
233}
234
235#[derive(Debug, Clone)]
236struct WmaState {
237    period: usize,
238    denom: f64,
239    window: VecDeque<f64>,
240    sum: f64,
241    weighted_sum: f64,
242}
243
244impl WmaState {
245    #[inline(always)]
246    fn new(period: usize) -> Self {
247        Self {
248            period,
249            denom: (period * (period + 1) / 2) as f64,
250            window: VecDeque::with_capacity(period.max(1)),
251            sum: 0.0,
252            weighted_sum: 0.0,
253        }
254    }
255
256    #[inline(always)]
257    fn reset(&mut self) {
258        self.window.clear();
259        self.sum = 0.0;
260        self.weighted_sum = 0.0;
261    }
262
263    #[inline(always)]
264    fn update(&mut self, value: f64) -> Option<f64> {
265        if !value.is_finite() {
266            self.reset();
267            return None;
268        }
269        if self.period == 1 {
270            return Some(value);
271        }
272        if self.window.len() < self.period {
273            let weight = self.window.len() + 1;
274            self.window.push_back(value);
275            self.sum += value;
276            self.weighted_sum += value * weight as f64;
277            if self.window.len() == self.period {
278                Some(self.weighted_sum / self.denom)
279            } else {
280                None
281            }
282        } else {
283            let oldest = self.window.pop_front().unwrap_or(0.0);
284            let old_sum = self.sum;
285            self.window.push_back(value);
286            self.sum = old_sum - oldest + value;
287            self.weighted_sum = self.weighted_sum - old_sum + self.period as f64 * value;
288            Some(self.weighted_sum / self.denom)
289        }
290    }
291}
292
293#[derive(Debug, Clone)]
294pub struct ProjectionOscillatorStream {
295    length: usize,
296    smooth_length: usize,
297    high_window: VecDeque<f64>,
298    low_window: VecDeque<f64>,
299    high_slopes: VecDeque<f64>,
300    low_slopes: VecDeque<f64>,
301    pbo_wma: WmaState,
302    signal_wma: WmaState,
303}
304
305impl ProjectionOscillatorStream {
306    #[inline(always)]
307    pub fn try_new(params: ProjectionOscillatorParams) -> Result<Self, ProjectionOscillatorError> {
308        let length = params.length.unwrap_or(14);
309        let smooth_length = params.smooth_length.unwrap_or(4);
310        validate_params(length, smooth_length)?;
311        Ok(Self {
312            length,
313            smooth_length,
314            high_window: VecDeque::with_capacity(length.max(1)),
315            low_window: VecDeque::with_capacity(length.max(1)),
316            high_slopes: VecDeque::with_capacity(length.max(1)),
317            low_slopes: VecDeque::with_capacity(length.max(1)),
318            pbo_wma: WmaState::new(smooth_length),
319            signal_wma: WmaState::new(smooth_length),
320        })
321    }
322
323    #[inline(always)]
324    pub fn reset(&mut self) {
325        self.high_window.clear();
326        self.low_window.clear();
327        self.high_slopes.clear();
328        self.low_slopes.clear();
329        self.pbo_wma.reset();
330        self.signal_wma.reset();
331    }
332
333    #[inline(always)]
334    pub fn update(&mut self, high: f64, low: f64, source: f64) -> Option<(f64, f64)> {
335        if !is_valid_triple(high, low, source) {
336            self.reset();
337            return None;
338        }
339
340        push_ring(&mut self.high_window, high, self.length);
341        push_ring(&mut self.low_window, low, self.length);
342
343        let high_slope = if self.high_window.len() == self.length {
344            linreg_slope_from_window(&self.high_window)
345        } else {
346            f64::NAN
347        };
348        let low_slope = if self.low_window.len() == self.length {
349            linreg_slope_from_window(&self.low_window)
350        } else {
351            f64::NAN
352        };
353        push_ring(&mut self.high_slopes, high_slope, self.length);
354        push_ring(&mut self.low_slopes, low_slope, self.length);
355
356        if self.high_window.len() != self.length
357            || self.low_window.len() != self.length
358            || self.high_slopes.len() != self.length
359            || self.low_slopes.len() != self.length
360            || self.high_slopes.iter().any(|v| !v.is_finite())
361            || self.low_slopes.iter().any(|v| !v.is_finite())
362        {
363            return None;
364        }
365
366        let mut upper = f64::NEG_INFINITY;
367        let mut lower = f64::INFINITY;
368        let last = self.length - 1;
369        for age in 0..self.length {
370            let idx = last - age;
371            let projected_high = self.high_window[idx] + self.high_slopes[idx] * age as f64;
372            let projected_low = self.low_window[idx] + self.low_slopes[idx] * age as f64;
373            if projected_high > upper {
374                upper = projected_high;
375            }
376            if projected_low < lower {
377                lower = projected_low;
378            }
379        }
380
381        let range = upper - lower;
382        let raw = if range.abs() <= f64::EPSILON {
383            0.0
384        } else {
385            100.0 * (source - lower) / range
386        };
387
388        let pbo = self.pbo_wma.update(raw)?;
389        let signal = self.signal_wma.update(pbo).unwrap_or(f64::NAN);
390        Some((pbo, signal))
391    }
392
393    #[inline(always)]
394    pub fn get_pbo_warmup_period(&self) -> usize {
395        pbo_warmup_prefix(self.length, self.smooth_length)
396    }
397
398    #[inline(always)]
399    pub fn get_signal_warmup_period(&self) -> usize {
400        signal_warmup_prefix(self.length, self.smooth_length)
401    }
402}
403
404#[inline(always)]
405fn push_ring(buf: &mut VecDeque<f64>, value: f64, cap: usize) {
406    if cap == 0 {
407        return;
408    }
409    if buf.len() == cap {
410        buf.pop_front();
411    }
412    buf.push_back(value);
413}
414
415#[inline(always)]
416fn linreg_slope_from_window(window: &VecDeque<f64>) -> f64 {
417    let n = window.len();
418    if n <= 1 {
419        return 0.0;
420    }
421    let nf = n as f64;
422    let sum_x = (n * (n - 1) / 2) as f64;
423    let sum_x2 = ((n - 1) * n * (2 * n - 1) / 6) as f64;
424    let denom = nf * sum_x2 - sum_x * sum_x;
425    if denom.abs() <= f64::EPSILON {
426        return 0.0;
427    }
428    let mut sum_y = 0.0;
429    let mut sum_xy = 0.0;
430    for (idx, &value) in window.iter().enumerate() {
431        let x = idx as f64;
432        sum_y += value;
433        sum_xy += x * value;
434    }
435    (nf * sum_xy - sum_x * sum_y) / denom
436}
437
438#[inline(always)]
439fn is_valid_triple(high: f64, low: f64, source: f64) -> bool {
440    high.is_finite() && low.is_finite() && source.is_finite()
441}
442
443#[inline(always)]
444fn input_slices<'a>(
445    input: &'a ProjectionOscillatorInput<'a>,
446) -> Result<(&'a [f64], &'a [f64], &'a [f64]), ProjectionOscillatorError> {
447    match &input.data {
448        ProjectionOscillatorData::Candles { candles, source } => Ok((
449            candles.high.as_slice(),
450            candles.low.as_slice(),
451            source_type(candles, source),
452        )),
453        ProjectionOscillatorData::Slices { high, low, source } => Ok((high, low, source)),
454    }
455}
456
457#[inline(always)]
458fn validate_params(length: usize, smooth_length: usize) -> Result<(), ProjectionOscillatorError> {
459    if length == 0 {
460        return Err(ProjectionOscillatorError::InvalidLength { length });
461    }
462    if smooth_length == 0 {
463        return Err(ProjectionOscillatorError::InvalidSmoothLength { smooth_length });
464    }
465    Ok(())
466}
467
468#[inline(always)]
469fn signal_needed_bars(
470    length: usize,
471    smooth_length: usize,
472) -> Result<usize, ProjectionOscillatorError> {
473    length
474        .checked_mul(2)
475        .and_then(|v| smooth_length.checked_mul(2).and_then(|s| v.checked_add(s)))
476        .and_then(|v| v.checked_sub(3))
477        .ok_or_else(|| ProjectionOscillatorError::InvalidInput {
478            msg: "projection_oscillator: warmup overflow".to_string(),
479        })
480}
481
482#[inline(always)]
483fn pbo_warmup_prefix(length: usize, smooth_length: usize) -> usize {
484    length
485        .saturating_mul(2)
486        .saturating_add(smooth_length)
487        .saturating_sub(3)
488}
489
490#[inline(always)]
491fn signal_warmup_prefix(length: usize, smooth_length: usize) -> usize {
492    length
493        .saturating_mul(2)
494        .saturating_add(smooth_length.saturating_mul(2))
495        .saturating_sub(4)
496}
497
498#[inline(always)]
499fn longest_valid_run(high: &[f64], low: &[f64], source: &[f64]) -> usize {
500    let mut best = 0usize;
501    let mut cur = 0usize;
502    for ((&h, &l), &s) in high.iter().zip(low.iter()).zip(source.iter()) {
503        if is_valid_triple(h, l, s) {
504            cur += 1;
505            best = best.max(cur);
506        } else {
507            cur = 0;
508        }
509    }
510    best
511}
512
513fn validate_common(
514    high: &[f64],
515    low: &[f64],
516    source: &[f64],
517    length: usize,
518    smooth_length: usize,
519) -> Result<(), ProjectionOscillatorError> {
520    validate_params(length, smooth_length)?;
521    if high.is_empty() || low.is_empty() || source.is_empty() {
522        return Err(ProjectionOscillatorError::EmptyInputData);
523    }
524    if high.len() != low.len() || high.len() != source.len() {
525        return Err(ProjectionOscillatorError::InputLengthMismatch {
526            high_len: high.len(),
527            low_len: low.len(),
528            source_len: source.len(),
529        });
530    }
531    let longest = longest_valid_run(high, low, source);
532    if longest == 0 {
533        return Err(ProjectionOscillatorError::AllValuesNaN);
534    }
535    let needed = signal_needed_bars(length, smooth_length)?;
536    if longest < needed {
537        return Err(ProjectionOscillatorError::NotEnoughValidData {
538            needed,
539            valid: longest,
540        });
541    }
542    Ok(())
543}
544
545#[inline(always)]
546fn compute_row(
547    high: &[f64],
548    low: &[f64],
549    source: &[f64],
550    length: usize,
551    smooth_length: usize,
552    out_pbo: &mut [f64],
553    out_signal: &mut [f64],
554) {
555    let mut stream = ProjectionOscillatorStream::try_new(ProjectionOscillatorParams {
556        length: Some(length),
557        smooth_length: Some(smooth_length),
558    })
559    .expect("validated params");
560
561    for i in 0..high.len() {
562        if let Some((pbo, signal)) = stream.update(high[i], low[i], source[i]) {
563            out_pbo[i] = pbo;
564            if signal.is_finite() {
565                out_signal[i] = signal;
566            }
567        }
568    }
569}
570
571pub fn projection_oscillator(
572    input: &ProjectionOscillatorInput,
573) -> Result<ProjectionOscillatorOutput, ProjectionOscillatorError> {
574    projection_oscillator_with_kernel(input, Kernel::Auto)
575}
576
577pub fn projection_oscillator_with_kernel(
578    input: &ProjectionOscillatorInput,
579    kernel: Kernel,
580) -> Result<ProjectionOscillatorOutput, ProjectionOscillatorError> {
581    let (high, low, source) = input_slices(input)?;
582    let length = input.get_length();
583    let smooth_length = input.get_smooth_length();
584    validate_common(high, low, source, length, smooth_length)?;
585
586    let _chosen = match kernel {
587        Kernel::Auto => detect_best_kernel(),
588        other => other,
589    };
590
591    let mut pbo = alloc_with_nan_prefix(high.len(), pbo_warmup_prefix(length, smooth_length));
592    let mut signal = alloc_with_nan_prefix(high.len(), signal_warmup_prefix(length, smooth_length));
593    compute_row(
594        high,
595        low,
596        source,
597        length,
598        smooth_length,
599        &mut pbo,
600        &mut signal,
601    );
602    Ok(ProjectionOscillatorOutput { pbo, signal })
603}
604
605pub fn projection_oscillator_into_slice(
606    out_pbo: &mut [f64],
607    out_signal: &mut [f64],
608    input: &ProjectionOscillatorInput,
609    kernel: Kernel,
610) -> Result<(), ProjectionOscillatorError> {
611    let (high, low, source) = input_slices(input)?;
612    if out_pbo.len() != high.len() {
613        return Err(ProjectionOscillatorError::OutputLengthMismatch {
614            expected: high.len(),
615            got: out_pbo.len(),
616        });
617    }
618    if out_signal.len() != high.len() {
619        return Err(ProjectionOscillatorError::OutputLengthMismatch {
620            expected: high.len(),
621            got: out_signal.len(),
622        });
623    }
624    let length = input.get_length();
625    let smooth_length = input.get_smooth_length();
626    validate_common(high, low, source, length, smooth_length)?;
627
628    let _chosen = match kernel {
629        Kernel::Auto => detect_best_kernel(),
630        other => other,
631    };
632
633    out_pbo.fill(f64::NAN);
634    out_signal.fill(f64::NAN);
635    compute_row(
636        high,
637        low,
638        source,
639        length,
640        smooth_length,
641        out_pbo,
642        out_signal,
643    );
644    Ok(())
645}
646
647#[cfg(not(target_arch = "wasm32"))]
648pub fn projection_oscillator_into(
649    input: &ProjectionOscillatorInput,
650    out_pbo: &mut [f64],
651    out_signal: &mut [f64],
652) -> Result<(), ProjectionOscillatorError> {
653    projection_oscillator_into_slice(out_pbo, out_signal, input, Kernel::Auto)
654}
655
656#[derive(Debug, Clone, Copy)]
657pub struct ProjectionOscillatorBatchRange {
658    pub length: (usize, usize, usize),
659    pub smooth_length: (usize, usize, usize),
660}
661
662impl Default for ProjectionOscillatorBatchRange {
663    fn default() -> Self {
664        Self {
665            length: (14, 14, 0),
666            smooth_length: (4, 4, 0),
667        }
668    }
669}
670
671#[derive(Debug, Clone)]
672pub struct ProjectionOscillatorBatchOutput {
673    pub pbo: Vec<f64>,
674    pub signal: Vec<f64>,
675    pub combos: Vec<ProjectionOscillatorParams>,
676    pub rows: usize,
677    pub cols: usize,
678}
679
680#[derive(Copy, Clone, Debug)]
681pub struct ProjectionOscillatorBatchBuilder {
682    range: ProjectionOscillatorBatchRange,
683    kernel: Kernel,
684}
685
686impl Default for ProjectionOscillatorBatchBuilder {
687    fn default() -> Self {
688        Self {
689            range: ProjectionOscillatorBatchRange::default(),
690            kernel: Kernel::Auto,
691        }
692    }
693}
694
695impl ProjectionOscillatorBatchBuilder {
696    #[inline(always)]
697    pub fn new() -> Self {
698        Self::default()
699    }
700
701    #[inline(always)]
702    pub fn length_range(mut self, value: (usize, usize, usize)) -> Self {
703        self.range.length = value;
704        self
705    }
706
707    #[inline(always)]
708    pub fn smooth_length_range(mut self, value: (usize, usize, usize)) -> Self {
709        self.range.smooth_length = value;
710        self
711    }
712
713    #[inline(always)]
714    pub fn kernel(mut self, value: Kernel) -> Self {
715        self.kernel = value;
716        self
717    }
718
719    #[inline(always)]
720    pub fn apply_slices(
721        self,
722        high: &[f64],
723        low: &[f64],
724        source: &[f64],
725    ) -> Result<ProjectionOscillatorBatchOutput, ProjectionOscillatorError> {
726        projection_oscillator_batch_with_kernel(high, low, source, &self.range, self.kernel)
727    }
728
729    #[inline(always)]
730    pub fn apply(
731        self,
732        candles: &Candles,
733    ) -> Result<ProjectionOscillatorBatchOutput, ProjectionOscillatorError> {
734        projection_oscillator_batch_with_kernel(
735            candles.high.as_slice(),
736            candles.low.as_slice(),
737            candles.close.as_slice(),
738            &self.range,
739            self.kernel,
740        )
741    }
742}
743
744fn expand_axis(range: (usize, usize, usize)) -> Result<Vec<usize>, ProjectionOscillatorError> {
745    let (start, end, step) = range;
746    if step == 0 {
747        return Ok(vec![start]);
748    }
749    if start > end {
750        return Err(ProjectionOscillatorError::InvalidRange { start, end, step });
751    }
752    let mut out = Vec::new();
753    let mut cur = start;
754    loop {
755        out.push(cur);
756        if cur >= end {
757            break;
758        }
759        let next =
760            cur.checked_add(step)
761                .ok_or_else(|| ProjectionOscillatorError::InvalidInput {
762                    msg: "projection_oscillator: range step overflow".to_string(),
763                })?;
764        if next <= cur {
765            return Err(ProjectionOscillatorError::InvalidRange { start, end, step });
766        }
767        cur = next.min(end);
768    }
769    Ok(out)
770}
771
772fn expand_grid_checked(
773    sweep: &ProjectionOscillatorBatchRange,
774) -> Result<Vec<ProjectionOscillatorParams>, ProjectionOscillatorError> {
775    let lengths = expand_axis(sweep.length)?;
776    let smooth_lengths = expand_axis(sweep.smooth_length)?;
777    let total = lengths
778        .len()
779        .checked_mul(smooth_lengths.len())
780        .ok_or_else(|| ProjectionOscillatorError::InvalidInput {
781            msg: "projection_oscillator: parameter grid size overflow".to_string(),
782        })?;
783    let mut out = Vec::with_capacity(total);
784    for &length in &lengths {
785        for &smooth_length in &smooth_lengths {
786            out.push(ProjectionOscillatorParams {
787                length: Some(length),
788                smooth_length: Some(smooth_length),
789            });
790        }
791    }
792    Ok(out)
793}
794
795pub fn expand_grid_projection_oscillator(
796    sweep: &ProjectionOscillatorBatchRange,
797) -> Result<Vec<ProjectionOscillatorParams>, ProjectionOscillatorError> {
798    expand_grid_checked(sweep)
799}
800
801pub fn projection_oscillator_batch_with_kernel(
802    high: &[f64],
803    low: &[f64],
804    source: &[f64],
805    sweep: &ProjectionOscillatorBatchRange,
806    kernel: Kernel,
807) -> Result<ProjectionOscillatorBatchOutput, ProjectionOscillatorError> {
808    projection_oscillator_batch_inner(high, low, source, sweep, kernel, true)
809}
810
811pub fn projection_oscillator_batch_slice(
812    high: &[f64],
813    low: &[f64],
814    source: &[f64],
815    sweep: &ProjectionOscillatorBatchRange,
816    kernel: Kernel,
817) -> Result<ProjectionOscillatorBatchOutput, ProjectionOscillatorError> {
818    projection_oscillator_batch_inner(high, low, source, sweep, kernel, false)
819}
820
821pub fn projection_oscillator_batch_par_slice(
822    high: &[f64],
823    low: &[f64],
824    source: &[f64],
825    sweep: &ProjectionOscillatorBatchRange,
826    kernel: Kernel,
827) -> Result<ProjectionOscillatorBatchOutput, ProjectionOscillatorError> {
828    projection_oscillator_batch_inner(high, low, source, sweep, kernel, true)
829}
830
831fn projection_oscillator_batch_inner(
832    high: &[f64],
833    low: &[f64],
834    source: &[f64],
835    sweep: &ProjectionOscillatorBatchRange,
836    kernel: Kernel,
837    parallel: bool,
838) -> Result<ProjectionOscillatorBatchOutput, ProjectionOscillatorError> {
839    let combos = expand_grid_checked(sweep)?;
840    let rows = combos.len();
841    let cols = high.len();
842    let total = rows
843        .checked_mul(cols)
844        .ok_or_else(|| ProjectionOscillatorError::InvalidInput {
845            msg: "projection_oscillator: rows*cols overflow in batch".to_string(),
846        })?;
847
848    if high.is_empty() || low.is_empty() || source.is_empty() {
849        return Err(ProjectionOscillatorError::EmptyInputData);
850    }
851    if high.len() != low.len() || high.len() != source.len() {
852        return Err(ProjectionOscillatorError::InputLengthMismatch {
853            high_len: high.len(),
854            low_len: low.len(),
855            source_len: source.len(),
856        });
857    }
858
859    let mut pbo_warmups = Vec::with_capacity(rows);
860    let mut signal_warmups = Vec::with_capacity(rows);
861    let mut max_needed = 0usize;
862    for combo in &combos {
863        let length = combo.length.unwrap_or(14);
864        let smooth_length = combo.smooth_length.unwrap_or(4);
865        validate_params(length, smooth_length)?;
866        pbo_warmups.push(pbo_warmup_prefix(length, smooth_length));
867        signal_warmups.push(signal_warmup_prefix(length, smooth_length));
868        max_needed = max_needed.max(signal_needed_bars(length, smooth_length)?);
869    }
870
871    let longest = longest_valid_run(high, low, source);
872    if longest == 0 {
873        return Err(ProjectionOscillatorError::AllValuesNaN);
874    }
875    if longest < max_needed {
876        return Err(ProjectionOscillatorError::NotEnoughValidData {
877            needed: max_needed,
878            valid: longest,
879        });
880    }
881
882    let mut pbo_mu = make_uninit_matrix(rows, cols);
883    let mut signal_mu = make_uninit_matrix(rows, cols);
884    init_matrix_prefixes(&mut pbo_mu, cols, &pbo_warmups);
885    init_matrix_prefixes(&mut signal_mu, cols, &signal_warmups);
886
887    let mut pbo = unsafe {
888        Vec::from_raw_parts(
889            pbo_mu.as_mut_ptr() as *mut f64,
890            pbo_mu.len(),
891            pbo_mu.capacity(),
892        )
893    };
894    let mut signal = unsafe {
895        Vec::from_raw_parts(
896            signal_mu.as_mut_ptr() as *mut f64,
897            signal_mu.len(),
898            signal_mu.capacity(),
899        )
900    };
901    std::mem::forget(pbo_mu);
902    std::mem::forget(signal_mu);
903    debug_assert_eq!(pbo.len(), total);
904    debug_assert_eq!(signal.len(), total);
905
906    projection_oscillator_batch_inner_into(
907        high,
908        low,
909        source,
910        sweep,
911        kernel,
912        parallel,
913        &mut pbo,
914        &mut signal,
915    )?;
916
917    Ok(ProjectionOscillatorBatchOutput {
918        pbo,
919        signal,
920        combos,
921        rows,
922        cols,
923    })
924}
925
926fn projection_oscillator_batch_inner_into(
927    high: &[f64],
928    low: &[f64],
929    source: &[f64],
930    sweep: &ProjectionOscillatorBatchRange,
931    kernel: Kernel,
932    parallel: bool,
933    out_pbo: &mut [f64],
934    out_signal: &mut [f64],
935) -> Result<Vec<ProjectionOscillatorParams>, ProjectionOscillatorError> {
936    match kernel {
937        Kernel::Auto
938        | Kernel::Scalar
939        | Kernel::ScalarBatch
940        | Kernel::Avx2
941        | Kernel::Avx2Batch
942        | Kernel::Avx512
943        | Kernel::Avx512Batch => {}
944        other => return Err(ProjectionOscillatorError::InvalidKernelForBatch(other)),
945    }
946
947    let combos = expand_grid_checked(sweep)?;
948    let len = high.len();
949    if len == 0 || low.is_empty() || source.is_empty() {
950        return Err(ProjectionOscillatorError::EmptyInputData);
951    }
952    if len != low.len() || len != source.len() {
953        return Err(ProjectionOscillatorError::InputLengthMismatch {
954            high_len: len,
955            low_len: low.len(),
956            source_len: source.len(),
957        });
958    }
959
960    let total =
961        combos
962            .len()
963            .checked_mul(len)
964            .ok_or_else(|| ProjectionOscillatorError::InvalidInput {
965                msg: "projection_oscillator: rows*cols overflow in batch_into".to_string(),
966            })?;
967    if out_pbo.len() != total {
968        return Err(ProjectionOscillatorError::MismatchedOutputLen {
969            dst_len: out_pbo.len(),
970            expected_len: total,
971        });
972    }
973    if out_signal.len() != total {
974        return Err(ProjectionOscillatorError::MismatchedOutputLen {
975            dst_len: out_signal.len(),
976            expected_len: total,
977        });
978    }
979
980    let longest = longest_valid_run(high, low, source);
981    if longest == 0 {
982        return Err(ProjectionOscillatorError::AllValuesNaN);
983    }
984    let mut max_needed = 0usize;
985    for combo in &combos {
986        let length = combo.length.unwrap_or(14);
987        let smooth_length = combo.smooth_length.unwrap_or(4);
988        validate_params(length, smooth_length)?;
989        max_needed = max_needed.max(signal_needed_bars(length, smooth_length)?);
990    }
991    if longest < max_needed {
992        return Err(ProjectionOscillatorError::NotEnoughValidData {
993            needed: max_needed,
994            valid: longest,
995        });
996    }
997
998    let _chosen = match kernel {
999        Kernel::Auto => detect_best_batch_kernel(),
1000        other => other,
1001    };
1002
1003    let worker = |row: usize, dst_pbo: &mut [f64], dst_signal: &mut [f64]| {
1004        let combo = &combos[row];
1005        dst_pbo.fill(f64::NAN);
1006        dst_signal.fill(f64::NAN);
1007        compute_row(
1008            high,
1009            low,
1010            source,
1011            combo.length.unwrap_or(14),
1012            combo.smooth_length.unwrap_or(4),
1013            dst_pbo,
1014            dst_signal,
1015        );
1016    };
1017
1018    if parallel && combos.len() > 1 {
1019        #[cfg(not(target_arch = "wasm32"))]
1020        {
1021            out_pbo
1022                .par_chunks_mut(len)
1023                .zip(out_signal.par_chunks_mut(len))
1024                .enumerate()
1025                .for_each(|(row, (dst_pbo, dst_signal))| worker(row, dst_pbo, dst_signal));
1026        }
1027        #[cfg(target_arch = "wasm32")]
1028        {
1029            for (row, (dst_pbo, dst_signal)) in out_pbo
1030                .chunks_mut(len)
1031                .zip(out_signal.chunks_mut(len))
1032                .enumerate()
1033            {
1034                worker(row, dst_pbo, dst_signal);
1035            }
1036        }
1037    } else {
1038        for (row, (dst_pbo, dst_signal)) in out_pbo
1039            .chunks_mut(len)
1040            .zip(out_signal.chunks_mut(len))
1041            .enumerate()
1042        {
1043            worker(row, dst_pbo, dst_signal);
1044        }
1045    }
1046
1047    Ok(combos)
1048}
1049
1050#[cfg(feature = "python")]
1051#[pyfunction(name = "projection_oscillator", signature = (high, low, source, length=14, smooth_length=4, kernel=None))]
1052pub fn projection_oscillator_py<'py>(
1053    py: Python<'py>,
1054    high: PyReadonlyArray1<'py, f64>,
1055    low: PyReadonlyArray1<'py, f64>,
1056    source: PyReadonlyArray1<'py, f64>,
1057    length: usize,
1058    smooth_length: usize,
1059    kernel: Option<&str>,
1060) -> PyResult<(Bound<'py, PyArray1<f64>>, Bound<'py, PyArray1<f64>>)> {
1061    let high = high.as_slice()?;
1062    let low = low.as_slice()?;
1063    let source = source.as_slice()?;
1064    let kern = validate_kernel(kernel, true)?;
1065    let input = ProjectionOscillatorInput::from_slices(
1066        high,
1067        low,
1068        source,
1069        ProjectionOscillatorParams {
1070            length: Some(length),
1071            smooth_length: Some(smooth_length),
1072        },
1073    );
1074    let out = py
1075        .allow_threads(|| projection_oscillator_with_kernel(&input, kern))
1076        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1077    Ok((out.pbo.into_pyarray(py), out.signal.into_pyarray(py)))
1078}
1079
1080#[cfg(feature = "python")]
1081#[pyclass(name = "ProjectionOscillatorStream")]
1082pub struct ProjectionOscillatorStreamPy {
1083    inner: ProjectionOscillatorStream,
1084}
1085
1086#[cfg(feature = "python")]
1087#[pymethods]
1088impl ProjectionOscillatorStreamPy {
1089    #[new]
1090    #[pyo3(signature = (length=14, smooth_length=4))]
1091    fn new(length: usize, smooth_length: usize) -> PyResult<Self> {
1092        let inner = ProjectionOscillatorStream::try_new(ProjectionOscillatorParams {
1093            length: Some(length),
1094            smooth_length: Some(smooth_length),
1095        })
1096        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1097        Ok(Self { inner })
1098    }
1099
1100    fn update(&mut self, high: f64, low: f64, source: f64) -> Option<(f64, f64)> {
1101        self.inner.update(high, low, source)
1102    }
1103
1104    fn reset(&mut self) {
1105        self.inner.reset();
1106    }
1107}
1108
1109#[cfg(feature = "python")]
1110#[pyfunction(name = "projection_oscillator_batch", signature = (high, low, source, length_range=(14, 14, 0), smooth_length_range=(4, 4, 0), kernel=None))]
1111pub fn projection_oscillator_batch_py<'py>(
1112    py: Python<'py>,
1113    high: PyReadonlyArray1<'py, f64>,
1114    low: PyReadonlyArray1<'py, f64>,
1115    source: PyReadonlyArray1<'py, f64>,
1116    length_range: (usize, usize, usize),
1117    smooth_length_range: (usize, usize, usize),
1118    kernel: Option<&str>,
1119) -> PyResult<Bound<'py, PyDict>> {
1120    let high = high.as_slice()?;
1121    let low = low.as_slice()?;
1122    let source = source.as_slice()?;
1123    let kern = validate_kernel(kernel, true)?;
1124
1125    let output = py
1126        .allow_threads(|| {
1127            projection_oscillator_batch_with_kernel(
1128                high,
1129                low,
1130                source,
1131                &ProjectionOscillatorBatchRange {
1132                    length: length_range,
1133                    smooth_length: smooth_length_range,
1134                },
1135                kern,
1136            )
1137        })
1138        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1139
1140    let dict = PyDict::new(py);
1141    dict.set_item(
1142        "pbo",
1143        output
1144            .pbo
1145            .into_pyarray(py)
1146            .reshape((output.rows, output.cols))?,
1147    )?;
1148    dict.set_item(
1149        "signal",
1150        output
1151            .signal
1152            .into_pyarray(py)
1153            .reshape((output.rows, output.cols))?,
1154    )?;
1155    dict.set_item(
1156        "lengths",
1157        output
1158            .combos
1159            .iter()
1160            .map(|params| params.length.unwrap_or(14) as u64)
1161            .collect::<Vec<_>>()
1162            .into_pyarray(py),
1163    )?;
1164    dict.set_item(
1165        "smooth_lengths",
1166        output
1167            .combos
1168            .iter()
1169            .map(|params| params.smooth_length.unwrap_or(4) as u64)
1170            .collect::<Vec<_>>()
1171            .into_pyarray(py),
1172    )?;
1173    dict.set_item("rows", output.rows)?;
1174    dict.set_item("cols", output.cols)?;
1175    Ok(dict)
1176}
1177
1178#[cfg(feature = "python")]
1179pub fn register_projection_oscillator_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
1180    m.add_function(wrap_pyfunction!(projection_oscillator_py, m)?)?;
1181    m.add_function(wrap_pyfunction!(projection_oscillator_batch_py, m)?)?;
1182    m.add_class::<ProjectionOscillatorStreamPy>()?;
1183    Ok(())
1184}
1185
1186#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1187#[derive(Debug, Clone, Serialize, Deserialize)]
1188pub struct ProjectionOscillatorBatchConfig {
1189    pub length_range: Vec<usize>,
1190    pub smooth_length_range: Vec<usize>,
1191}
1192
1193#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1194#[wasm_bindgen(js_name = projection_oscillator_js)]
1195pub fn projection_oscillator_js(
1196    high: &[f64],
1197    low: &[f64],
1198    source: &[f64],
1199    length: usize,
1200    smooth_length: usize,
1201) -> Result<JsValue, JsValue> {
1202    let input = ProjectionOscillatorInput::from_slices(
1203        high,
1204        low,
1205        source,
1206        ProjectionOscillatorParams {
1207            length: Some(length),
1208            smooth_length: Some(smooth_length),
1209        },
1210    );
1211    let out = projection_oscillator_with_kernel(&input, Kernel::Auto)
1212        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1213    let obj = js_sys::Object::new();
1214    js_sys::Reflect::set(
1215        &obj,
1216        &JsValue::from_str("pbo"),
1217        &serde_wasm_bindgen::to_value(&out.pbo).unwrap(),
1218    )?;
1219    js_sys::Reflect::set(
1220        &obj,
1221        &JsValue::from_str("signal"),
1222        &serde_wasm_bindgen::to_value(&out.signal).unwrap(),
1223    )?;
1224    Ok(obj.into())
1225}
1226
1227#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1228#[wasm_bindgen(js_name = projection_oscillator_batch_js)]
1229pub fn projection_oscillator_batch_js(
1230    high: &[f64],
1231    low: &[f64],
1232    source: &[f64],
1233    config: JsValue,
1234) -> Result<JsValue, JsValue> {
1235    let config: ProjectionOscillatorBatchConfig = serde_wasm_bindgen::from_value(config)
1236        .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
1237    if config.length_range.len() != 3 || config.smooth_length_range.len() != 3 {
1238        return Err(JsValue::from_str(
1239            "Invalid config: every range must have exactly 3 elements [start, end, step]",
1240        ));
1241    }
1242    let out = projection_oscillator_batch_with_kernel(
1243        high,
1244        low,
1245        source,
1246        &ProjectionOscillatorBatchRange {
1247            length: (
1248                config.length_range[0],
1249                config.length_range[1],
1250                config.length_range[2],
1251            ),
1252            smooth_length: (
1253                config.smooth_length_range[0],
1254                config.smooth_length_range[1],
1255                config.smooth_length_range[2],
1256            ),
1257        },
1258        Kernel::Auto,
1259    )
1260    .map_err(|e| JsValue::from_str(&e.to_string()))?;
1261    let obj = js_sys::Object::new();
1262    js_sys::Reflect::set(
1263        &obj,
1264        &JsValue::from_str("pbo"),
1265        &serde_wasm_bindgen::to_value(&out.pbo).unwrap(),
1266    )?;
1267    js_sys::Reflect::set(
1268        &obj,
1269        &JsValue::from_str("signal"),
1270        &serde_wasm_bindgen::to_value(&out.signal).unwrap(),
1271    )?;
1272    js_sys::Reflect::set(
1273        &obj,
1274        &JsValue::from_str("rows"),
1275        &JsValue::from_f64(out.rows as f64),
1276    )?;
1277    js_sys::Reflect::set(
1278        &obj,
1279        &JsValue::from_str("cols"),
1280        &JsValue::from_f64(out.cols as f64),
1281    )?;
1282    js_sys::Reflect::set(
1283        &obj,
1284        &JsValue::from_str("combos"),
1285        &serde_wasm_bindgen::to_value(&out.combos).unwrap(),
1286    )?;
1287    Ok(obj.into())
1288}
1289
1290#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1291#[wasm_bindgen]
1292pub fn projection_oscillator_alloc(len: usize) -> *mut f64 {
1293    let mut vec = Vec::<f64>::with_capacity(2 * len);
1294    let ptr = vec.as_mut_ptr();
1295    std::mem::forget(vec);
1296    ptr
1297}
1298
1299#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1300#[wasm_bindgen]
1301pub fn projection_oscillator_free(ptr: *mut f64, len: usize) {
1302    if !ptr.is_null() {
1303        unsafe {
1304            let _ = Vec::from_raw_parts(ptr, 2 * len, 2 * len);
1305        }
1306    }
1307}
1308
1309#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1310#[wasm_bindgen]
1311pub fn projection_oscillator_into(
1312    high_ptr: *const f64,
1313    low_ptr: *const f64,
1314    source_ptr: *const f64,
1315    out_ptr: *mut f64,
1316    len: usize,
1317    length: usize,
1318    smooth_length: usize,
1319) -> Result<(), JsValue> {
1320    if high_ptr.is_null() || low_ptr.is_null() || source_ptr.is_null() || out_ptr.is_null() {
1321        return Err(JsValue::from_str(
1322            "null pointer passed to projection_oscillator_into",
1323        ));
1324    }
1325    unsafe {
1326        let high = std::slice::from_raw_parts(high_ptr, len);
1327        let low = std::slice::from_raw_parts(low_ptr, len);
1328        let source = std::slice::from_raw_parts(source_ptr, len);
1329        let out = std::slice::from_raw_parts_mut(out_ptr, 2 * len);
1330        let (dst_pbo, dst_signal) = out.split_at_mut(len);
1331        let input = ProjectionOscillatorInput::from_slices(
1332            high,
1333            low,
1334            source,
1335            ProjectionOscillatorParams {
1336                length: Some(length),
1337                smooth_length: Some(smooth_length),
1338            },
1339        );
1340        projection_oscillator_into_slice(dst_pbo, dst_signal, &input, Kernel::Auto)
1341            .map_err(|e| JsValue::from_str(&e.to_string()))
1342    }
1343}
1344
1345#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1346#[wasm_bindgen]
1347pub fn projection_oscillator_batch_into(
1348    high_ptr: *const f64,
1349    low_ptr: *const f64,
1350    source_ptr: *const f64,
1351    out_ptr: *mut f64,
1352    len: usize,
1353    length_start: usize,
1354    length_end: usize,
1355    length_step: usize,
1356    smooth_length_start: usize,
1357    smooth_length_end: usize,
1358    smooth_length_step: usize,
1359) -> Result<usize, JsValue> {
1360    if high_ptr.is_null() || low_ptr.is_null() || source_ptr.is_null() || out_ptr.is_null() {
1361        return Err(JsValue::from_str(
1362            "null pointer passed to projection_oscillator_batch_into",
1363        ));
1364    }
1365    let sweep = ProjectionOscillatorBatchRange {
1366        length: (length_start, length_end, length_step),
1367        smooth_length: (smooth_length_start, smooth_length_end, smooth_length_step),
1368    };
1369    let combos = expand_grid_checked(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
1370    let rows = combos.len();
1371    let total = rows
1372        .checked_mul(len)
1373        .and_then(|v| v.checked_mul(2))
1374        .ok_or_else(|| {
1375            JsValue::from_str("rows*cols overflow in projection_oscillator_batch_into")
1376        })?;
1377    unsafe {
1378        let high = std::slice::from_raw_parts(high_ptr, len);
1379        let low = std::slice::from_raw_parts(low_ptr, len);
1380        let source = std::slice::from_raw_parts(source_ptr, len);
1381        let out = std::slice::from_raw_parts_mut(out_ptr, total);
1382        let split = rows * len;
1383        let (dst_pbo, dst_signal) = out.split_at_mut(split);
1384        projection_oscillator_batch_inner_into(
1385            high,
1386            low,
1387            source,
1388            &sweep,
1389            Kernel::Auto,
1390            false,
1391            dst_pbo,
1392            dst_signal,
1393        )
1394        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1395    }
1396    Ok(rows)
1397}
1398
1399#[cfg(test)]
1400mod tests {
1401    use super::*;
1402    use crate::indicators::dispatch::{
1403        compute_cpu, IndicatorComputeRequest, IndicatorDataRef, ParamKV, ParamValue,
1404    };
1405
1406    fn sample_ohlc(len: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
1407        let high: Vec<f64> = (0..len)
1408            .map(|i| {
1409                let x = i as f64;
1410                101.0 + x * 0.04 + (x * 0.11).sin() * 1.4 + (x * 0.017).cos() * 0.5
1411            })
1412            .collect();
1413        let low: Vec<f64> = high
1414            .iter()
1415            .enumerate()
1416            .map(|(i, &h)| h - 1.2 - ((i as f64) * 0.07).cos().abs() * 0.35)
1417            .collect();
1418        let close: Vec<f64> = high
1419            .iter()
1420            .zip(low.iter())
1421            .enumerate()
1422            .map(|(i, (&h, &l))| l + (h - l) * (0.35 + 0.25 * ((i as f64) * 0.09).sin().abs()))
1423            .collect();
1424        (high, low, close)
1425    }
1426
1427    fn naive_projection_oscillator(
1428        high: &[f64],
1429        low: &[f64],
1430        source: &[f64],
1431        length: usize,
1432        smooth_length: usize,
1433    ) -> (Vec<f64>, Vec<f64>) {
1434        let mut pbo = vec![f64::NAN; high.len()];
1435        let mut signal = vec![f64::NAN; high.len()];
1436        compute_row(
1437            high,
1438            low,
1439            source,
1440            length,
1441            smooth_length,
1442            &mut pbo,
1443            &mut signal,
1444        );
1445        (pbo, signal)
1446    }
1447
1448    fn assert_series_close(left: &[f64], right: &[f64], tol: f64) {
1449        assert_eq!(left.len(), right.len());
1450        for (a, b) in left.iter().zip(right.iter()) {
1451            if a.is_nan() || b.is_nan() {
1452                assert!(a.is_nan() && b.is_nan());
1453            } else {
1454                assert!((a - b).abs() <= tol, "left={a} right={b}");
1455            }
1456        }
1457    }
1458
1459    #[test]
1460    fn projection_oscillator_matches_naive() -> Result<(), Box<dyn Error>> {
1461        let (high, low, close) = sample_ohlc(256);
1462        let input = ProjectionOscillatorInput::from_slices(
1463            &high,
1464            &low,
1465            &close,
1466            ProjectionOscillatorParams::default(),
1467        );
1468        let out = projection_oscillator_with_kernel(&input, Kernel::Scalar)?;
1469        let (expected_pbo, expected_signal) =
1470            naive_projection_oscillator(&high, &low, &close, 14, 4);
1471        assert_series_close(&out.pbo, &expected_pbo, 1e-12);
1472        assert_series_close(&out.signal, &expected_signal, 1e-12);
1473        Ok(())
1474    }
1475
1476    #[test]
1477    fn projection_oscillator_into_matches_api() -> Result<(), Box<dyn Error>> {
1478        let (high, low, close) = sample_ohlc(200);
1479        let input = ProjectionOscillatorInput::from_slices(
1480            &high,
1481            &low,
1482            &close,
1483            ProjectionOscillatorParams {
1484                length: Some(10),
1485                smooth_length: Some(3),
1486            },
1487        );
1488        let base = projection_oscillator(&input)?;
1489        let mut pbo = vec![0.0; close.len()];
1490        let mut signal = vec![0.0; close.len()];
1491        projection_oscillator_into_slice(&mut pbo, &mut signal, &input, Kernel::Auto)?;
1492        assert_series_close(&base.pbo, &pbo, 1e-12);
1493        assert_series_close(&base.signal, &signal, 1e-12);
1494        Ok(())
1495    }
1496
1497    #[test]
1498    fn projection_oscillator_stream_matches_batch() -> Result<(), Box<dyn Error>> {
1499        let (high, low, close) = sample_ohlc(220);
1500        let params = ProjectionOscillatorParams {
1501            length: Some(14),
1502            smooth_length: Some(4),
1503        };
1504        let batch = projection_oscillator(&ProjectionOscillatorInput::from_slices(
1505            &high,
1506            &low,
1507            &close,
1508            params.clone(),
1509        ))?;
1510        let mut stream = ProjectionOscillatorStream::try_new(params)?;
1511        let mut pbo = Vec::with_capacity(close.len());
1512        let mut signal = Vec::with_capacity(close.len());
1513        for i in 0..close.len() {
1514            if let Some((p, s)) = stream.update(high[i], low[i], close[i]) {
1515                pbo.push(p);
1516                signal.push(s);
1517            } else {
1518                pbo.push(f64::NAN);
1519                signal.push(f64::NAN);
1520            }
1521        }
1522        assert_series_close(&batch.pbo, &pbo, 1e-12);
1523        assert_series_close(&batch.signal, &signal, 1e-12);
1524        Ok(())
1525    }
1526
1527    #[test]
1528    fn projection_oscillator_batch_single_matches_single() -> Result<(), Box<dyn Error>> {
1529        let (high, low, close) = sample_ohlc(180);
1530        let single = projection_oscillator(&ProjectionOscillatorInput::from_slices(
1531            &high,
1532            &low,
1533            &close,
1534            ProjectionOscillatorParams::default(),
1535        ))?;
1536        let batch = projection_oscillator_batch_with_kernel(
1537            &high,
1538            &low,
1539            &close,
1540            &ProjectionOscillatorBatchRange::default(),
1541            Kernel::Auto,
1542        )?;
1543        assert_eq!(batch.rows, 1);
1544        assert_eq!(batch.cols, close.len());
1545        assert_series_close(&batch.pbo, &single.pbo, 1e-12);
1546        assert_series_close(&batch.signal, &single.signal, 1e-12);
1547        Ok(())
1548    }
1549
1550    #[test]
1551    fn projection_oscillator_rejects_invalid_params() {
1552        let (high, low, close) = sample_ohlc(64);
1553        let err = projection_oscillator(&ProjectionOscillatorInput::from_slices(
1554            &high,
1555            &low,
1556            &close,
1557            ProjectionOscillatorParams {
1558                length: Some(0),
1559                ..ProjectionOscillatorParams::default()
1560            },
1561        ))
1562        .unwrap_err();
1563        assert!(matches!(
1564            err,
1565            ProjectionOscillatorError::InvalidLength { .. }
1566        ));
1567    }
1568
1569    #[test]
1570    fn projection_oscillator_dispatch_compute_returns_pbo() -> Result<(), Box<dyn Error>> {
1571        let (high, low, close) = sample_ohlc(160);
1572        let out = compute_cpu(IndicatorComputeRequest {
1573            indicator_id: "projection_oscillator",
1574            output_id: Some("pbo"),
1575            data: IndicatorDataRef::Ohlc {
1576                open: &close,
1577                high: &high,
1578                low: &low,
1579                close: &close,
1580            },
1581            params: &[
1582                ParamKV {
1583                    key: "length",
1584                    value: ParamValue::Int(14),
1585                },
1586                ParamKV {
1587                    key: "smooth_length",
1588                    value: ParamValue::Int(4),
1589                },
1590            ],
1591            kernel: Kernel::Auto,
1592        })?;
1593        assert_eq!(out.output_id, "pbo");
1594        assert_eq!(out.cols, close.len());
1595        Ok(())
1596    }
1597}