Skip to main content

vector_ta/indicators/
neighboring_trailing_stop.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#[cfg(feature = "python")]
10use pyo3::wrap_pyfunction;
11
12#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
13use serde::{Deserialize, Serialize};
14#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
15use wasm_bindgen::prelude::*;
16
17use crate::utilities::data_loader::Candles;
18use crate::utilities::enums::Kernel;
19use crate::utilities::helpers::{
20    alloc_with_nan_prefix, detect_best_batch_kernel, init_matrix_prefixes, make_uninit_matrix,
21};
22#[cfg(feature = "python")]
23use crate::utilities::kernel_validation::validate_kernel;
24#[cfg(not(target_arch = "wasm32"))]
25use rayon::prelude::*;
26use std::collections::VecDeque;
27use std::mem::ManuallyDrop;
28use thiserror::Error;
29
30const DEFAULT_BUFFER_SIZE: usize = 200;
31const DEFAULT_K: usize = 50;
32const DEFAULT_PERCENTILE: f64 = 90.0;
33const DEFAULT_SMOOTH: usize = 5;
34const MIN_BUFFER_SIZE: usize = 100;
35const MIN_K: usize = 5;
36const FLOAT_TOL: f64 = 1e-12;
37
38#[derive(Debug, Clone)]
39pub enum NeighboringTrailingStopData<'a> {
40    Candles(&'a Candles),
41    Slices {
42        high: &'a [f64],
43        low: &'a [f64],
44        close: &'a [f64],
45    },
46}
47
48#[derive(Debug, Clone)]
49pub struct NeighboringTrailingStopOutput {
50    pub trailing_stop: Vec<f64>,
51    pub bullish_band: Vec<f64>,
52    pub bearish_band: Vec<f64>,
53    pub direction: Vec<f64>,
54    pub discovery_bull: Vec<f64>,
55    pub discovery_bear: Vec<f64>,
56}
57
58#[derive(Debug, Clone, Copy)]
59pub struct NeighboringTrailingStopPoint {
60    pub trailing_stop: f64,
61    pub bullish_band: f64,
62    pub bearish_band: f64,
63    pub direction: f64,
64    pub discovery_bull: f64,
65    pub discovery_bear: f64,
66}
67
68impl NeighboringTrailingStopPoint {
69    #[inline(always)]
70    fn nan() -> Self {
71        Self {
72            trailing_stop: f64::NAN,
73            bullish_band: f64::NAN,
74            bearish_band: f64::NAN,
75            direction: f64::NAN,
76            discovery_bull: f64::NAN,
77            discovery_bear: f64::NAN,
78        }
79    }
80}
81
82#[derive(Debug, Clone, PartialEq)]
83#[cfg_attr(
84    all(target_arch = "wasm32", feature = "wasm"),
85    derive(Serialize, Deserialize)
86)]
87pub struct NeighboringTrailingStopParams {
88    pub buffer_size: Option<usize>,
89    pub k: Option<usize>,
90    pub percentile: Option<f64>,
91    pub smooth: Option<usize>,
92}
93
94impl Default for NeighboringTrailingStopParams {
95    fn default() -> Self {
96        Self {
97            buffer_size: Some(DEFAULT_BUFFER_SIZE),
98            k: Some(DEFAULT_K),
99            percentile: Some(DEFAULT_PERCENTILE),
100            smooth: Some(DEFAULT_SMOOTH),
101        }
102    }
103}
104
105#[derive(Debug, Clone)]
106pub struct NeighboringTrailingStopInput<'a> {
107    pub data: NeighboringTrailingStopData<'a>,
108    pub params: NeighboringTrailingStopParams,
109}
110
111impl<'a> NeighboringTrailingStopInput<'a> {
112    #[inline]
113    pub fn from_candles(candles: &'a Candles, params: NeighboringTrailingStopParams) -> Self {
114        Self {
115            data: NeighboringTrailingStopData::Candles(candles),
116            params,
117        }
118    }
119
120    #[inline]
121    pub fn from_slices(
122        high: &'a [f64],
123        low: &'a [f64],
124        close: &'a [f64],
125        params: NeighboringTrailingStopParams,
126    ) -> Self {
127        Self {
128            data: NeighboringTrailingStopData::Slices { high, low, close },
129            params,
130        }
131    }
132
133    #[inline]
134    pub fn with_default_candles(candles: &'a Candles) -> Self {
135        Self::from_candles(candles, NeighboringTrailingStopParams::default())
136    }
137
138    #[inline]
139    pub fn as_slices(&self) -> (&'a [f64], &'a [f64], &'a [f64]) {
140        match &self.data {
141            NeighboringTrailingStopData::Candles(candles) => {
142                (&candles.high, &candles.low, &candles.close)
143            }
144            NeighboringTrailingStopData::Slices { high, low, close } => (high, low, close),
145        }
146    }
147}
148
149#[derive(Clone, Copy, Debug, Default)]
150pub struct NeighboringTrailingStopBuilder {
151    buffer_size: Option<usize>,
152    k: Option<usize>,
153    percentile: Option<f64>,
154    smooth: Option<usize>,
155    kernel: Kernel,
156}
157
158impl NeighboringTrailingStopBuilder {
159    #[inline]
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    #[inline]
165    pub fn buffer_size(mut self, value: usize) -> Self {
166        self.buffer_size = Some(value);
167        self
168    }
169
170    #[inline]
171    pub fn k(mut self, value: usize) -> Self {
172        self.k = Some(value);
173        self
174    }
175
176    #[inline]
177    pub fn percentile(mut self, value: f64) -> Self {
178        self.percentile = Some(value);
179        self
180    }
181
182    #[inline]
183    pub fn smooth(mut self, value: usize) -> Self {
184        self.smooth = Some(value);
185        self
186    }
187
188    #[inline]
189    pub fn kernel(mut self, kernel: Kernel) -> Self {
190        self.kernel = kernel;
191        self
192    }
193
194    #[inline]
195    pub fn apply(
196        self,
197        candles: &Candles,
198    ) -> Result<NeighboringTrailingStopOutput, NeighboringTrailingStopError> {
199        let input = NeighboringTrailingStopInput::from_candles(
200            candles,
201            NeighboringTrailingStopParams {
202                buffer_size: self.buffer_size,
203                k: self.k,
204                percentile: self.percentile,
205                smooth: self.smooth,
206            },
207        );
208        neighboring_trailing_stop_with_kernel(&input, self.kernel)
209    }
210
211    #[inline]
212    pub fn apply_slices(
213        self,
214        high: &[f64],
215        low: &[f64],
216        close: &[f64],
217    ) -> Result<NeighboringTrailingStopOutput, NeighboringTrailingStopError> {
218        let input = NeighboringTrailingStopInput::from_slices(
219            high,
220            low,
221            close,
222            NeighboringTrailingStopParams {
223                buffer_size: self.buffer_size,
224                k: self.k,
225                percentile: self.percentile,
226                smooth: self.smooth,
227            },
228        );
229        neighboring_trailing_stop_with_kernel(&input, self.kernel)
230    }
231
232    #[inline]
233    pub fn into_stream(
234        self,
235    ) -> Result<NeighboringTrailingStopStream, NeighboringTrailingStopError> {
236        NeighboringTrailingStopStream::try_new(NeighboringTrailingStopParams {
237            buffer_size: self.buffer_size,
238            k: self.k,
239            percentile: self.percentile,
240            smooth: self.smooth,
241        })
242    }
243}
244
245#[derive(Debug, Error)]
246pub enum NeighboringTrailingStopError {
247    #[error("neighboring_trailing_stop: Input data slice is empty.")]
248    EmptyInputData,
249    #[error("neighboring_trailing_stop: All values are NaN.")]
250    AllValuesNaN,
251    #[error(
252        "neighboring_trailing_stop: Inconsistent slice lengths - high={high_len}, low={low_len}, close={close_len}"
253    )]
254    MismatchedInputLengths {
255        high_len: usize,
256        low_len: usize,
257        close_len: usize,
258    },
259    #[error(
260        "neighboring_trailing_stop: Invalid buffer_size: buffer_size = {buffer_size}, min = {min}"
261    )]
262    InvalidBufferSize { buffer_size: usize, min: usize },
263    #[error("neighboring_trailing_stop: Invalid k: k = {k}, min = {min}")]
264    InvalidK { k: usize, min: usize },
265    #[error("neighboring_trailing_stop: Invalid percentile: {percentile}")]
266    InvalidPercentile { percentile: f64 },
267    #[error("neighboring_trailing_stop: Invalid smooth: {smooth}")]
268    InvalidSmooth { smooth: usize },
269    #[error("neighboring_trailing_stop: Output length mismatch: expected = {expected}")]
270    OutputLengthMismatch { expected: usize },
271    #[error("neighboring_trailing_stop: Invalid range: start={start}, end={end}, step={step}")]
272    InvalidRange {
273        start: String,
274        end: String,
275        step: String,
276    },
277    #[error("neighboring_trailing_stop: Invalid kernel for batch: {0:?}")]
278    InvalidKernelForBatch(Kernel),
279}
280
281#[derive(Clone, Copy, Debug)]
282struct ResolvedParams {
283    buffer_size: usize,
284    k: usize,
285    percentile: f64,
286    smooth: usize,
287}
288
289#[inline(always)]
290fn first_valid_ohlc(high: &[f64], low: &[f64], close: &[f64]) -> usize {
291    let len = high.len();
292    let mut i = 0usize;
293    while i < len {
294        if high[i].is_finite() && low[i].is_finite() && close[i].is_finite() {
295            return i;
296        }
297        i += 1;
298    }
299    len
300}
301
302#[inline(always)]
303fn resolve_params(
304    params: &NeighboringTrailingStopParams,
305) -> Result<ResolvedParams, NeighboringTrailingStopError> {
306    let buffer_size = params.buffer_size.unwrap_or(DEFAULT_BUFFER_SIZE);
307    let k = params.k.unwrap_or(DEFAULT_K);
308    let percentile = params.percentile.unwrap_or(DEFAULT_PERCENTILE);
309    let smooth = params.smooth.unwrap_or(DEFAULT_SMOOTH);
310
311    if buffer_size < MIN_BUFFER_SIZE {
312        return Err(NeighboringTrailingStopError::InvalidBufferSize {
313            buffer_size,
314            min: MIN_BUFFER_SIZE,
315        });
316    }
317    if k < MIN_K {
318        return Err(NeighboringTrailingStopError::InvalidK { k, min: MIN_K });
319    }
320    if !percentile.is_finite() || !(1.0..=99.0).contains(&percentile) {
321        return Err(NeighboringTrailingStopError::InvalidPercentile { percentile });
322    }
323    if smooth == 0 {
324        return Err(NeighboringTrailingStopError::InvalidSmooth { smooth });
325    }
326
327    Ok(ResolvedParams {
328        buffer_size,
329        k,
330        percentile,
331        smooth,
332    })
333}
334
335#[inline(always)]
336fn lower_bound(sorted: &[f64], value: f64) -> usize {
337    let mut left = 0usize;
338    let mut right = sorted.len();
339    while left < right {
340        let mid = left + ((right - left) >> 1);
341        if sorted[mid] < value {
342            left = mid + 1;
343        } else {
344            right = mid;
345        }
346    }
347    left
348}
349
350#[inline(always)]
351fn insert_sorted(sorted: &mut Vec<f64>, value: f64) {
352    let idx = lower_bound(sorted, value);
353    sorted.insert(idx, value);
354}
355
356#[inline(always)]
357fn remove_sorted_once(sorted: &mut Vec<f64>, value: f64) {
358    let idx = lower_bound(sorted, value);
359    if idx < sorted.len() && sorted[idx] == value {
360        sorted.remove(idx);
361    }
362}
363
364#[inline(always)]
365fn percentile_sorted_slice(sorted: &[f64], percentile: f64) -> f64 {
366    let len = sorted.len();
367    if len == 0 {
368        return f64::NAN;
369    }
370    if len == 1 {
371        return sorted[0];
372    }
373
374    let idx = (len.saturating_sub(1)) as f64 * percentile / 100.0;
375    let i1 = idx.floor() as usize;
376    let i2 = idx.ceil() as usize;
377    if i1 == i2 {
378        sorted[i1]
379    } else {
380        let v1 = sorted[i1];
381        let v2 = sorted[i2];
382        v1 + (v2 - v1) * (idx - i1 as f64)
383    }
384}
385
386#[derive(Clone, Debug)]
387struct SmaIgnoreNa {
388    period: usize,
389    values: VecDeque<f64>,
390    sum: f64,
391}
392
393impl SmaIgnoreNa {
394    #[inline]
395    fn new(period: usize) -> Self {
396        Self {
397            period,
398            values: VecDeque::with_capacity(period.max(1)),
399            sum: 0.0,
400        }
401    }
402
403    #[inline]
404    fn update(&mut self, value: f64) -> f64 {
405        if value.is_finite() {
406            self.values.push_back(value);
407            self.sum += value;
408            if self.values.len() > self.period {
409                if let Some(old) = self.values.pop_front() {
410                    self.sum -= old;
411                }
412            }
413        }
414
415        if self.values.len() == self.period {
416            self.sum / self.period as f64
417        } else {
418            f64::NAN
419        }
420    }
421
422    #[inline]
423    fn reset(&mut self) {
424        self.values.clear();
425        self.sum = 0.0;
426    }
427}
428
429#[derive(Clone, Debug)]
430struct CoreState {
431    params: ResolvedParams,
432    price_buffer: VecDeque<f64>,
433    sorted: Vec<f64>,
434    bull_sma: SmaIgnoreNa,
435    bear_sma: SmaIgnoreNa,
436    direction: i8,
437    trailing_stop: f64,
438}
439
440impl CoreState {
441    #[inline]
442    fn new(params: ResolvedParams) -> Self {
443        Self {
444            price_buffer: VecDeque::with_capacity(params.buffer_size.max(params.smooth)),
445            sorted: Vec::with_capacity(params.buffer_size.max(params.k)),
446            bull_sma: SmaIgnoreNa::new(params.smooth),
447            bear_sma: SmaIgnoreNa::new(params.smooth),
448            params,
449            direction: 0,
450            trailing_stop: f64::NAN,
451        }
452    }
453
454    #[inline]
455    fn reset(&mut self) {
456        self.price_buffer.clear();
457        self.sorted.clear();
458        self.bull_sma.reset();
459        self.bear_sma.reset();
460        self.direction = 0;
461        self.trailing_stop = f64::NAN;
462    }
463
464    #[inline]
465    fn update(&mut self, high: f64, low: f64, close: f64) -> NeighboringTrailingStopPoint {
466        let mut bear_val = f64::NAN;
467        let mut bull_val = f64::NAN;
468        let size = self.sorted.len();
469
470        if size > 5 {
471            let idx = lower_bound(&self.sorted, close);
472            let bear_start = idx.saturating_sub(self.params.k);
473            if idx > bear_start {
474                bear_val = percentile_sorted_slice(
475                    &self.sorted[bear_start..idx],
476                    100.0 - self.params.percentile,
477                );
478            }
479
480            if size > 0 {
481                let bull_end = (idx + self.params.k).min(size - 1);
482                if bull_end > idx {
483                    bull_val = percentile_sorted_slice(
484                        &self.sorted[idx..(bull_end + 1)],
485                        self.params.percentile,
486                    );
487                }
488            }
489        }
490
491        if self.price_buffer.len() >= self.params.buffer_size {
492            if let Some(old) = self.price_buffer.pop_front() {
493                remove_sorted_once(&mut self.sorted, old);
494            }
495        }
496        self.price_buffer.push_back(close);
497        insert_sorted(&mut self.sorted, close);
498
499        let final_bull = self.bull_sma.update(bull_val);
500        let final_bear = self.bear_sma.update(bear_val);
501        let discovery_bull = bull_val.is_nan() && bear_val.is_finite();
502        let discovery_bear = bear_val.is_nan() && bull_val.is_finite();
503
504        let prev_direction = self.direction;
505        if discovery_bull {
506            self.direction = 1;
507        } else if discovery_bear {
508            self.direction = -1;
509        }
510
511        if self.direction > prev_direction {
512            self.trailing_stop = if final_bear.is_finite() {
513                final_bear
514            } else {
515                low
516            };
517        } else if self.direction < prev_direction {
518            self.trailing_stop = if final_bull.is_finite() {
519                final_bull
520            } else {
521                high
522            };
523        }
524
525        if self.direction == 1 {
526            let candidate = if final_bear.is_finite() {
527                final_bear
528            } else {
529                self.trailing_stop
530            };
531            self.trailing_stop = if self.trailing_stop.is_finite() {
532                self.trailing_stop.max(candidate)
533            } else {
534                candidate
535            };
536        } else if self.direction == -1 {
537            let candidate = if final_bull.is_finite() {
538                final_bull
539            } else {
540                self.trailing_stop
541            };
542            self.trailing_stop = if self.trailing_stop.is_finite() {
543                self.trailing_stop.min(candidate)
544            } else {
545                candidate
546            };
547        }
548
549        NeighboringTrailingStopPoint {
550            trailing_stop: self.trailing_stop,
551            bullish_band: final_bull,
552            bearish_band: final_bear,
553            direction: self.direction as f64,
554            discovery_bull: if discovery_bull { 1.0 } else { 0.0 },
555            discovery_bear: if discovery_bear { 1.0 } else { 0.0 },
556        }
557    }
558}
559
560#[derive(Debug, Clone)]
561pub struct NeighboringTrailingStopStream {
562    state: CoreState,
563}
564
565impl NeighboringTrailingStopStream {
566    #[inline]
567    pub fn try_new(
568        params: NeighboringTrailingStopParams,
569    ) -> Result<Self, NeighboringTrailingStopError> {
570        let params = resolve_params(&params)?;
571        Ok(Self {
572            state: CoreState::new(params),
573        })
574    }
575
576    #[inline]
577    pub fn update(
578        &mut self,
579        high: f64,
580        low: f64,
581        close: f64,
582    ) -> Option<NeighboringTrailingStopPoint> {
583        if !high.is_finite() || !low.is_finite() || !close.is_finite() {
584            self.state.reset();
585            return None;
586        }
587        Some(self.state.update(high, low, close))
588    }
589
590    #[inline]
591    pub fn reset(&mut self) {
592        self.state.reset();
593    }
594
595    #[inline]
596    pub fn get_warmup_period(&self) -> usize {
597        0
598    }
599}
600
601#[allow(clippy::too_many_arguments)]
602#[inline(always)]
603fn neighboring_trailing_stop_row_from_slices(
604    high: &[f64],
605    low: &[f64],
606    close: &[f64],
607    params: ResolvedParams,
608    trailing_stop: &mut [f64],
609    bullish_band: &mut [f64],
610    bearish_band: &mut [f64],
611    direction: &mut [f64],
612    discovery_bull: &mut [f64],
613    discovery_bear: &mut [f64],
614) {
615    let len = high.len();
616    debug_assert_eq!(low.len(), len);
617    debug_assert_eq!(close.len(), len);
618    debug_assert_eq!(trailing_stop.len(), len);
619    debug_assert_eq!(bullish_band.len(), len);
620    debug_assert_eq!(bearish_band.len(), len);
621    debug_assert_eq!(direction.len(), len);
622    debug_assert_eq!(discovery_bull.len(), len);
623    debug_assert_eq!(discovery_bear.len(), len);
624
625    let mut state = CoreState::new(params);
626    let mut i = 0usize;
627    while i < len {
628        let h = high[i];
629        let l = low[i];
630        let c = close[i];
631        let point = if h.is_finite() && l.is_finite() && c.is_finite() {
632            state.update(h, l, c)
633        } else {
634            state.reset();
635            NeighboringTrailingStopPoint::nan()
636        };
637        trailing_stop[i] = point.trailing_stop;
638        bullish_band[i] = point.bullish_band;
639        bearish_band[i] = point.bearish_band;
640        direction[i] = point.direction;
641        discovery_bull[i] = point.discovery_bull;
642        discovery_bear[i] = point.discovery_bear;
643        i += 1;
644    }
645}
646
647#[inline]
648pub fn neighboring_trailing_stop(
649    input: &NeighboringTrailingStopInput,
650) -> Result<NeighboringTrailingStopOutput, NeighboringTrailingStopError> {
651    neighboring_trailing_stop_with_kernel(input, Kernel::Auto)
652}
653
654#[inline]
655pub fn neighboring_trailing_stop_with_kernel(
656    input: &NeighboringTrailingStopInput,
657    _kernel: Kernel,
658) -> Result<NeighboringTrailingStopOutput, NeighboringTrailingStopError> {
659    let (high, low, close) = input.as_slices();
660    if high.is_empty() || low.is_empty() || close.is_empty() {
661        return Err(NeighboringTrailingStopError::EmptyInputData);
662    }
663    if high.len() != low.len() || high.len() != close.len() {
664        return Err(NeighboringTrailingStopError::MismatchedInputLengths {
665            high_len: high.len(),
666            low_len: low.len(),
667            close_len: close.len(),
668        });
669    }
670    if first_valid_ohlc(high, low, close) >= high.len() {
671        return Err(NeighboringTrailingStopError::AllValuesNaN);
672    }
673
674    let params = resolve_params(&input.params)?;
675    let len = close.len();
676    let mut trailing_stop = alloc_with_nan_prefix(len, 0);
677    let mut bullish_band = alloc_with_nan_prefix(len, 0);
678    let mut bearish_band = alloc_with_nan_prefix(len, 0);
679    let mut direction = alloc_with_nan_prefix(len, 0);
680    let mut discovery_bull = alloc_with_nan_prefix(len, 0);
681    let mut discovery_bear = alloc_with_nan_prefix(len, 0);
682
683    neighboring_trailing_stop_row_from_slices(
684        high,
685        low,
686        close,
687        params,
688        &mut trailing_stop,
689        &mut bullish_band,
690        &mut bearish_band,
691        &mut direction,
692        &mut discovery_bull,
693        &mut discovery_bear,
694    );
695
696    Ok(NeighboringTrailingStopOutput {
697        trailing_stop,
698        bullish_band,
699        bearish_band,
700        direction,
701        discovery_bull,
702        discovery_bear,
703    })
704}
705
706#[allow(clippy::too_many_arguments)]
707pub fn neighboring_trailing_stop_into_slices(
708    trailing_stop_out: &mut [f64],
709    bullish_band_out: &mut [f64],
710    bearish_band_out: &mut [f64],
711    direction_out: &mut [f64],
712    discovery_bull_out: &mut [f64],
713    discovery_bear_out: &mut [f64],
714    input: &NeighboringTrailingStopInput,
715    _kernel: Kernel,
716) -> Result<(), NeighboringTrailingStopError> {
717    let (high, low, close) = input.as_slices();
718    if high.is_empty() || low.is_empty() || close.is_empty() {
719        return Err(NeighboringTrailingStopError::EmptyInputData);
720    }
721    if high.len() != low.len() || high.len() != close.len() {
722        return Err(NeighboringTrailingStopError::MismatchedInputLengths {
723            high_len: high.len(),
724            low_len: low.len(),
725            close_len: close.len(),
726        });
727    }
728    let expected = high.len();
729    if trailing_stop_out.len() != expected
730        || bullish_band_out.len() != expected
731        || bearish_band_out.len() != expected
732        || direction_out.len() != expected
733        || discovery_bull_out.len() != expected
734        || discovery_bear_out.len() != expected
735    {
736        return Err(NeighboringTrailingStopError::OutputLengthMismatch { expected });
737    }
738    if first_valid_ohlc(high, low, close) >= high.len() {
739        return Err(NeighboringTrailingStopError::AllValuesNaN);
740    }
741
742    let params = resolve_params(&input.params)?;
743    neighboring_trailing_stop_row_from_slices(
744        high,
745        low,
746        close,
747        params,
748        trailing_stop_out,
749        bullish_band_out,
750        bearish_band_out,
751        direction_out,
752        discovery_bull_out,
753        discovery_bear_out,
754    );
755    Ok(())
756}
757
758#[allow(clippy::too_many_arguments)]
759#[inline]
760#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
761pub fn neighboring_trailing_stop_into(
762    trailing_stop_out: &mut [f64],
763    bullish_band_out: &mut [f64],
764    bearish_band_out: &mut [f64],
765    direction_out: &mut [f64],
766    discovery_bull_out: &mut [f64],
767    discovery_bear_out: &mut [f64],
768    input: &NeighboringTrailingStopInput,
769) -> Result<(), NeighboringTrailingStopError> {
770    neighboring_trailing_stop_into_slices(
771        trailing_stop_out,
772        bullish_band_out,
773        bearish_band_out,
774        direction_out,
775        discovery_bull_out,
776        discovery_bear_out,
777        input,
778        Kernel::Auto,
779    )
780}
781
782#[derive(Debug, Clone, PartialEq)]
783pub struct NeighboringTrailingStopBatchRange {
784    pub buffer_size: (usize, usize, usize),
785    pub k: (usize, usize, usize),
786    pub percentile: (f64, f64, f64),
787    pub smooth: (usize, usize, usize),
788}
789
790impl Default for NeighboringTrailingStopBatchRange {
791    fn default() -> Self {
792        Self {
793            buffer_size: (DEFAULT_BUFFER_SIZE, DEFAULT_BUFFER_SIZE, 0),
794            k: (DEFAULT_K, DEFAULT_K, 0),
795            percentile: (DEFAULT_PERCENTILE, DEFAULT_PERCENTILE, 0.0),
796            smooth: (DEFAULT_SMOOTH, DEFAULT_SMOOTH, 0),
797        }
798    }
799}
800
801#[derive(Debug, Clone)]
802pub struct NeighboringTrailingStopBatchOutput {
803    pub trailing_stop: Vec<f64>,
804    pub bullish_band: Vec<f64>,
805    pub bearish_band: Vec<f64>,
806    pub direction: Vec<f64>,
807    pub discovery_bull: Vec<f64>,
808    pub discovery_bear: Vec<f64>,
809    pub combos: Vec<NeighboringTrailingStopParams>,
810    pub rows: usize,
811    pub cols: usize,
812}
813
814impl NeighboringTrailingStopBatchOutput {
815    #[inline]
816    pub fn params_for(&self, row: usize) -> Option<&NeighboringTrailingStopParams> {
817        self.combos.get(row)
818    }
819
820    #[inline]
821    pub fn row_slices(
822        &self,
823        row: usize,
824    ) -> Option<(&[f64], &[f64], &[f64], &[f64], &[f64], &[f64])> {
825        if row >= self.rows {
826            return None;
827        }
828        let start = row * self.cols;
829        let end = start + self.cols;
830        Some((
831            &self.trailing_stop[start..end],
832            &self.bullish_band[start..end],
833            &self.bearish_band[start..end],
834            &self.direction[start..end],
835            &self.discovery_bull[start..end],
836            &self.discovery_bear[start..end],
837        ))
838    }
839}
840
841#[derive(Clone, Debug, Default)]
842pub struct NeighboringTrailingStopBatchBuilder {
843    range: NeighboringTrailingStopBatchRange,
844    kernel: Kernel,
845}
846
847impl NeighboringTrailingStopBatchBuilder {
848    #[inline]
849    pub fn new() -> Self {
850        Self::default()
851    }
852
853    #[inline]
854    pub fn kernel(mut self, kernel: Kernel) -> Self {
855        self.kernel = kernel;
856        self
857    }
858
859    #[inline]
860    pub fn buffer_size_range(mut self, start: usize, end: usize, step: usize) -> Self {
861        self.range.buffer_size = (start, end, step);
862        self
863    }
864
865    #[inline]
866    pub fn k_range(mut self, start: usize, end: usize, step: usize) -> Self {
867        self.range.k = (start, end, step);
868        self
869    }
870
871    #[inline]
872    pub fn percentile_range(mut self, start: f64, end: f64, step: f64) -> Self {
873        self.range.percentile = (start, end, step);
874        self
875    }
876
877    #[inline]
878    pub fn smooth_range(mut self, start: usize, end: usize, step: usize) -> Self {
879        self.range.smooth = (start, end, step);
880        self
881    }
882
883    #[inline]
884    pub fn apply_slices(
885        self,
886        high: &[f64],
887        low: &[f64],
888        close: &[f64],
889    ) -> Result<NeighboringTrailingStopBatchOutput, NeighboringTrailingStopError> {
890        neighboring_trailing_stop_batch_with_kernel(high, low, close, &self.range, self.kernel)
891    }
892
893    #[inline]
894    pub fn apply_candles(
895        self,
896        candles: &Candles,
897    ) -> Result<NeighboringTrailingStopBatchOutput, NeighboringTrailingStopError> {
898        self.apply_slices(&candles.high, &candles.low, &candles.close)
899    }
900}
901
902#[inline(always)]
903fn expand_axis_usize(
904    (start, end, step): (usize, usize, usize),
905) -> Result<Vec<usize>, NeighboringTrailingStopError> {
906    if step == 0 || start == end {
907        return Ok(vec![start]);
908    }
909    let mut out = Vec::new();
910    if start < end {
911        let mut value = start;
912        while value <= end {
913            out.push(value);
914            let next = value.saturating_add(step);
915            if next == value {
916                break;
917            }
918            value = next;
919        }
920    } else {
921        let mut value = start;
922        loop {
923            out.push(value);
924            if value == end {
925                break;
926            }
927            let next = value.saturating_sub(step);
928            if next == value || next < end {
929                break;
930            }
931            value = next;
932        }
933    }
934    if out.is_empty() {
935        return Err(NeighboringTrailingStopError::InvalidRange {
936            start: start.to_string(),
937            end: end.to_string(),
938            step: step.to_string(),
939        });
940    }
941    Ok(out)
942}
943
944#[inline(always)]
945fn expand_axis_f64(
946    start: f64,
947    end: f64,
948    step: f64,
949) -> Result<Vec<f64>, NeighboringTrailingStopError> {
950    if !start.is_finite() || !end.is_finite() || !step.is_finite() || start > end {
951        return Err(NeighboringTrailingStopError::InvalidRange {
952            start: start.to_string(),
953            end: end.to_string(),
954            step: step.to_string(),
955        });
956    }
957    if (start - end).abs() < FLOAT_TOL {
958        if step.abs() > FLOAT_TOL {
959            return Err(NeighboringTrailingStopError::InvalidRange {
960                start: start.to_string(),
961                end: end.to_string(),
962                step: step.to_string(),
963            });
964        }
965        return Ok(vec![start]);
966    }
967    if step <= 0.0 {
968        return Err(NeighboringTrailingStopError::InvalidRange {
969            start: start.to_string(),
970            end: end.to_string(),
971            step: step.to_string(),
972        });
973    }
974    let mut out = Vec::new();
975    let mut value = start;
976    while value <= end + FLOAT_TOL {
977        out.push(value.min(end));
978        value += step;
979    }
980    if (out.last().copied().unwrap_or(start) - end).abs() > 1e-9 {
981        return Err(NeighboringTrailingStopError::InvalidRange {
982            start: start.to_string(),
983            end: end.to_string(),
984            step: step.to_string(),
985        });
986    }
987    Ok(out)
988}
989
990fn expand_grid_neighboring_trailing_stop(
991    sweep: &NeighboringTrailingStopBatchRange,
992) -> Result<Vec<NeighboringTrailingStopParams>, NeighboringTrailingStopError> {
993    let buffer_sizes = expand_axis_usize(sweep.buffer_size)?;
994    let ks = expand_axis_usize(sweep.k)?;
995    let percentiles = expand_axis_f64(sweep.percentile.0, sweep.percentile.1, sweep.percentile.2)?;
996    let smooths = expand_axis_usize(sweep.smooth)?;
997
998    let capacity = buffer_sizes
999        .len()
1000        .saturating_mul(ks.len())
1001        .saturating_mul(percentiles.len())
1002        .saturating_mul(smooths.len());
1003    let mut combos = Vec::with_capacity(capacity);
1004    for buffer_size in buffer_sizes {
1005        for &k in &ks {
1006            for &percentile in &percentiles {
1007                for &smooth in &smooths {
1008                    let params = NeighboringTrailingStopParams {
1009                        buffer_size: Some(buffer_size),
1010                        k: Some(k),
1011                        percentile: Some(percentile),
1012                        smooth: Some(smooth),
1013                    };
1014                    let _ = resolve_params(&params)?;
1015                    combos.push(params);
1016                }
1017            }
1018        }
1019    }
1020    Ok(combos)
1021}
1022
1023#[inline]
1024pub fn neighboring_trailing_stop_batch_with_kernel(
1025    high: &[f64],
1026    low: &[f64],
1027    close: &[f64],
1028    sweep: &NeighboringTrailingStopBatchRange,
1029    kernel: Kernel,
1030) -> Result<NeighboringTrailingStopBatchOutput, NeighboringTrailingStopError> {
1031    let batch_kernel = match kernel {
1032        Kernel::Auto => detect_best_batch_kernel(),
1033        other if other.is_batch() => other,
1034        other => return Err(NeighboringTrailingStopError::InvalidKernelForBatch(other)),
1035    };
1036    neighboring_trailing_stop_batch_par_slices(high, low, close, sweep, batch_kernel.to_non_batch())
1037}
1038
1039#[inline]
1040pub fn neighboring_trailing_stop_batch_slices(
1041    high: &[f64],
1042    low: &[f64],
1043    close: &[f64],
1044    sweep: &NeighboringTrailingStopBatchRange,
1045    kernel: Kernel,
1046) -> Result<NeighboringTrailingStopBatchOutput, NeighboringTrailingStopError> {
1047    neighboring_trailing_stop_batch_inner(high, low, close, sweep, kernel, false)
1048}
1049
1050#[inline]
1051pub fn neighboring_trailing_stop_batch_par_slices(
1052    high: &[f64],
1053    low: &[f64],
1054    close: &[f64],
1055    sweep: &NeighboringTrailingStopBatchRange,
1056    kernel: Kernel,
1057) -> Result<NeighboringTrailingStopBatchOutput, NeighboringTrailingStopError> {
1058    neighboring_trailing_stop_batch_inner(high, low, close, sweep, kernel, true)
1059}
1060
1061#[allow(clippy::too_many_lines)]
1062pub fn neighboring_trailing_stop_batch_inner(
1063    high: &[f64],
1064    low: &[f64],
1065    close: &[f64],
1066    sweep: &NeighboringTrailingStopBatchRange,
1067    _kernel: Kernel,
1068    parallel: bool,
1069) -> Result<NeighboringTrailingStopBatchOutput, NeighboringTrailingStopError> {
1070    if high.is_empty() || low.is_empty() || close.is_empty() {
1071        return Err(NeighboringTrailingStopError::EmptyInputData);
1072    }
1073    if high.len() != low.len() || high.len() != close.len() {
1074        return Err(NeighboringTrailingStopError::MismatchedInputLengths {
1075            high_len: high.len(),
1076            low_len: low.len(),
1077            close_len: close.len(),
1078        });
1079    }
1080    if first_valid_ohlc(high, low, close) >= high.len() {
1081        return Err(NeighboringTrailingStopError::AllValuesNaN);
1082    }
1083
1084    let combos = expand_grid_neighboring_trailing_stop(sweep)?;
1085    let resolved = combos
1086        .iter()
1087        .map(resolve_params)
1088        .collect::<Result<Vec<_>, _>>()?;
1089    let rows = combos.len();
1090    let cols = close.len();
1091    let total =
1092        rows.checked_mul(cols)
1093            .ok_or(NeighboringTrailingStopError::OutputLengthMismatch {
1094                expected: usize::MAX,
1095            })?;
1096    let zero_prefixes = vec![0usize; rows];
1097
1098    let mut trailing_stop_mu = make_uninit_matrix(rows, cols);
1099    init_matrix_prefixes(&mut trailing_stop_mu, cols, &zero_prefixes);
1100    let mut trailing_stop_guard = ManuallyDrop::new(trailing_stop_mu);
1101    let trailing_stop_out = unsafe {
1102        std::slice::from_raw_parts_mut(trailing_stop_guard.as_mut_ptr() as *mut f64, total)
1103    };
1104
1105    let mut bullish_band_mu = make_uninit_matrix(rows, cols);
1106    init_matrix_prefixes(&mut bullish_band_mu, cols, &zero_prefixes);
1107    let mut bullish_band_guard = ManuallyDrop::new(bullish_band_mu);
1108    let bullish_band_out = unsafe {
1109        std::slice::from_raw_parts_mut(bullish_band_guard.as_mut_ptr() as *mut f64, total)
1110    };
1111
1112    let mut bearish_band_mu = make_uninit_matrix(rows, cols);
1113    init_matrix_prefixes(&mut bearish_band_mu, cols, &zero_prefixes);
1114    let mut bearish_band_guard = ManuallyDrop::new(bearish_band_mu);
1115    let bearish_band_out = unsafe {
1116        std::slice::from_raw_parts_mut(bearish_band_guard.as_mut_ptr() as *mut f64, total)
1117    };
1118
1119    let mut direction_mu = make_uninit_matrix(rows, cols);
1120    init_matrix_prefixes(&mut direction_mu, cols, &zero_prefixes);
1121    let mut direction_guard = ManuallyDrop::new(direction_mu);
1122    let direction_out =
1123        unsafe { std::slice::from_raw_parts_mut(direction_guard.as_mut_ptr() as *mut f64, total) };
1124
1125    let mut discovery_bull_mu = make_uninit_matrix(rows, cols);
1126    init_matrix_prefixes(&mut discovery_bull_mu, cols, &zero_prefixes);
1127    let mut discovery_bull_guard = ManuallyDrop::new(discovery_bull_mu);
1128    let discovery_bull_out = unsafe {
1129        std::slice::from_raw_parts_mut(discovery_bull_guard.as_mut_ptr() as *mut f64, total)
1130    };
1131
1132    let mut discovery_bear_mu = make_uninit_matrix(rows, cols);
1133    init_matrix_prefixes(&mut discovery_bear_mu, cols, &zero_prefixes);
1134    let mut discovery_bear_guard = ManuallyDrop::new(discovery_bear_mu);
1135    let discovery_bear_out = unsafe {
1136        std::slice::from_raw_parts_mut(discovery_bear_guard.as_mut_ptr() as *mut f64, total)
1137    };
1138
1139    if parallel {
1140        #[cfg(not(target_arch = "wasm32"))]
1141        {
1142            let trailing_stop_ptr = trailing_stop_out.as_mut_ptr() as usize;
1143            let bullish_band_ptr = bullish_band_out.as_mut_ptr() as usize;
1144            let bearish_band_ptr = bearish_band_out.as_mut_ptr() as usize;
1145            let direction_ptr = direction_out.as_mut_ptr() as usize;
1146            let discovery_bull_ptr = discovery_bull_out.as_mut_ptr() as usize;
1147            let discovery_bear_ptr = discovery_bear_out.as_mut_ptr() as usize;
1148
1149            resolved
1150                .par_iter()
1151                .enumerate()
1152                .for_each(|(row, params)| unsafe {
1153                    let start = row * cols;
1154                    neighboring_trailing_stop_row_from_slices(
1155                        high,
1156                        low,
1157                        close,
1158                        *params,
1159                        std::slice::from_raw_parts_mut(
1160                            (trailing_stop_ptr as *mut f64).add(start),
1161                            cols,
1162                        ),
1163                        std::slice::from_raw_parts_mut(
1164                            (bullish_band_ptr as *mut f64).add(start),
1165                            cols,
1166                        ),
1167                        std::slice::from_raw_parts_mut(
1168                            (bearish_band_ptr as *mut f64).add(start),
1169                            cols,
1170                        ),
1171                        std::slice::from_raw_parts_mut(
1172                            (direction_ptr as *mut f64).add(start),
1173                            cols,
1174                        ),
1175                        std::slice::from_raw_parts_mut(
1176                            (discovery_bull_ptr as *mut f64).add(start),
1177                            cols,
1178                        ),
1179                        std::slice::from_raw_parts_mut(
1180                            (discovery_bear_ptr as *mut f64).add(start),
1181                            cols,
1182                        ),
1183                    );
1184                });
1185        }
1186
1187        #[cfg(target_arch = "wasm32")]
1188        for (row, params) in resolved.iter().enumerate() {
1189            let start = row * cols;
1190            let end = start + cols;
1191            neighboring_trailing_stop_row_from_slices(
1192                high,
1193                low,
1194                close,
1195                *params,
1196                &mut trailing_stop_out[start..end],
1197                &mut bullish_band_out[start..end],
1198                &mut bearish_band_out[start..end],
1199                &mut direction_out[start..end],
1200                &mut discovery_bull_out[start..end],
1201                &mut discovery_bear_out[start..end],
1202            );
1203        }
1204    } else {
1205        for (row, params) in resolved.iter().enumerate() {
1206            let start = row * cols;
1207            let end = start + cols;
1208            neighboring_trailing_stop_row_from_slices(
1209                high,
1210                low,
1211                close,
1212                *params,
1213                &mut trailing_stop_out[start..end],
1214                &mut bullish_band_out[start..end],
1215                &mut bearish_band_out[start..end],
1216                &mut direction_out[start..end],
1217                &mut discovery_bull_out[start..end],
1218                &mut discovery_bear_out[start..end],
1219            );
1220        }
1221    }
1222
1223    let trailing_stop = unsafe {
1224        Vec::from_raw_parts(
1225            trailing_stop_guard.as_mut_ptr() as *mut f64,
1226            trailing_stop_guard.len(),
1227            trailing_stop_guard.capacity(),
1228        )
1229    };
1230    let bullish_band = unsafe {
1231        Vec::from_raw_parts(
1232            bullish_band_guard.as_mut_ptr() as *mut f64,
1233            bullish_band_guard.len(),
1234            bullish_band_guard.capacity(),
1235        )
1236    };
1237    let bearish_band = unsafe {
1238        Vec::from_raw_parts(
1239            bearish_band_guard.as_mut_ptr() as *mut f64,
1240            bearish_band_guard.len(),
1241            bearish_band_guard.capacity(),
1242        )
1243    };
1244    let direction = unsafe {
1245        Vec::from_raw_parts(
1246            direction_guard.as_mut_ptr() as *mut f64,
1247            direction_guard.len(),
1248            direction_guard.capacity(),
1249        )
1250    };
1251    let discovery_bull = unsafe {
1252        Vec::from_raw_parts(
1253            discovery_bull_guard.as_mut_ptr() as *mut f64,
1254            discovery_bull_guard.len(),
1255            discovery_bull_guard.capacity(),
1256        )
1257    };
1258    let discovery_bear = unsafe {
1259        Vec::from_raw_parts(
1260            discovery_bear_guard.as_mut_ptr() as *mut f64,
1261            discovery_bear_guard.len(),
1262            discovery_bear_guard.capacity(),
1263        )
1264    };
1265    core::mem::forget(trailing_stop_guard);
1266    core::mem::forget(bullish_band_guard);
1267    core::mem::forget(bearish_band_guard);
1268    core::mem::forget(direction_guard);
1269    core::mem::forget(discovery_bull_guard);
1270    core::mem::forget(discovery_bear_guard);
1271
1272    Ok(NeighboringTrailingStopBatchOutput {
1273        trailing_stop,
1274        bullish_band,
1275        bearish_band,
1276        direction,
1277        discovery_bull,
1278        discovery_bear,
1279        combos,
1280        rows,
1281        cols,
1282    })
1283}
1284
1285#[allow(clippy::too_many_arguments)]
1286pub fn neighboring_trailing_stop_batch_inner_into(
1287    high: &[f64],
1288    low: &[f64],
1289    close: &[f64],
1290    sweep: &NeighboringTrailingStopBatchRange,
1291    kernel: Kernel,
1292    parallel: bool,
1293    trailing_stop: &mut [f64],
1294    bullish_band: &mut [f64],
1295    bearish_band: &mut [f64],
1296    direction: &mut [f64],
1297    discovery_bull: &mut [f64],
1298    discovery_bear: &mut [f64],
1299) -> Result<Vec<NeighboringTrailingStopParams>, NeighboringTrailingStopError> {
1300    let out = neighboring_trailing_stop_batch_inner(high, low, close, sweep, kernel, parallel)?;
1301    let total = out.rows * out.cols;
1302    if trailing_stop.len() != total
1303        || bullish_band.len() != total
1304        || bearish_band.len() != total
1305        || direction.len() != total
1306        || discovery_bull.len() != total
1307        || discovery_bear.len() != total
1308    {
1309        return Err(NeighboringTrailingStopError::OutputLengthMismatch { expected: total });
1310    }
1311    trailing_stop.copy_from_slice(&out.trailing_stop);
1312    bullish_band.copy_from_slice(&out.bullish_band);
1313    bearish_band.copy_from_slice(&out.bearish_band);
1314    direction.copy_from_slice(&out.direction);
1315    discovery_bull.copy_from_slice(&out.discovery_bull);
1316    discovery_bear.copy_from_slice(&out.discovery_bear);
1317    Ok(out.combos)
1318}
1319
1320#[cfg(feature = "python")]
1321#[pyfunction(name = "neighboring_trailing_stop")]
1322#[pyo3(signature = (
1323    high,
1324    low,
1325    close,
1326    buffer_size=DEFAULT_BUFFER_SIZE,
1327    k=DEFAULT_K,
1328    percentile=DEFAULT_PERCENTILE,
1329    smooth=DEFAULT_SMOOTH,
1330    kernel=None
1331))]
1332pub fn neighboring_trailing_stop_py<'py>(
1333    py: Python<'py>,
1334    high: PyReadonlyArray1<'py, f64>,
1335    low: PyReadonlyArray1<'py, f64>,
1336    close: PyReadonlyArray1<'py, f64>,
1337    buffer_size: usize,
1338    k: usize,
1339    percentile: f64,
1340    smooth: usize,
1341    kernel: Option<&str>,
1342) -> PyResult<(
1343    Bound<'py, PyArray1<f64>>,
1344    Bound<'py, PyArray1<f64>>,
1345    Bound<'py, PyArray1<f64>>,
1346    Bound<'py, PyArray1<f64>>,
1347    Bound<'py, PyArray1<f64>>,
1348    Bound<'py, PyArray1<f64>>,
1349)> {
1350    let high = high.as_slice()?;
1351    let low = low.as_slice()?;
1352    let close = close.as_slice()?;
1353    let kernel = validate_kernel(kernel, false)?;
1354    let input = NeighboringTrailingStopInput::from_slices(
1355        high,
1356        low,
1357        close,
1358        NeighboringTrailingStopParams {
1359            buffer_size: Some(buffer_size),
1360            k: Some(k),
1361            percentile: Some(percentile),
1362            smooth: Some(smooth),
1363        },
1364    );
1365    let out = py
1366        .allow_threads(|| neighboring_trailing_stop_with_kernel(&input, kernel))
1367        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1368    Ok((
1369        out.trailing_stop.into_pyarray(py),
1370        out.bullish_band.into_pyarray(py),
1371        out.bearish_band.into_pyarray(py),
1372        out.direction.into_pyarray(py),
1373        out.discovery_bull.into_pyarray(py),
1374        out.discovery_bear.into_pyarray(py),
1375    ))
1376}
1377
1378#[cfg(feature = "python")]
1379#[pyclass(name = "NeighboringTrailingStopStream")]
1380pub struct NeighboringTrailingStopStreamPy {
1381    stream: NeighboringTrailingStopStream,
1382}
1383
1384#[cfg(feature = "python")]
1385#[pymethods]
1386impl NeighboringTrailingStopStreamPy {
1387    #[new]
1388    #[pyo3(signature = (
1389        buffer_size=DEFAULT_BUFFER_SIZE,
1390        k=DEFAULT_K,
1391        percentile=DEFAULT_PERCENTILE,
1392        smooth=DEFAULT_SMOOTH
1393    ))]
1394    fn new(buffer_size: usize, k: usize, percentile: f64, smooth: usize) -> PyResult<Self> {
1395        let stream = NeighboringTrailingStopStream::try_new(NeighboringTrailingStopParams {
1396            buffer_size: Some(buffer_size),
1397            k: Some(k),
1398            percentile: Some(percentile),
1399            smooth: Some(smooth),
1400        })
1401        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1402        Ok(Self { stream })
1403    }
1404
1405    fn update(
1406        &mut self,
1407        high: f64,
1408        low: f64,
1409        close: f64,
1410    ) -> Option<(f64, f64, f64, f64, f64, f64)> {
1411        self.stream.update(high, low, close).map(|point| {
1412            (
1413                point.trailing_stop,
1414                point.bullish_band,
1415                point.bearish_band,
1416                point.direction,
1417                point.discovery_bull,
1418                point.discovery_bear,
1419            )
1420        })
1421    }
1422
1423    fn reset(&mut self) {
1424        self.stream.reset();
1425    }
1426
1427    #[getter]
1428    fn warmup_period(&self) -> usize {
1429        self.stream.get_warmup_period()
1430    }
1431}
1432
1433#[cfg(feature = "python")]
1434#[pyfunction(name = "neighboring_trailing_stop_batch")]
1435#[pyo3(signature = (
1436    high,
1437    low,
1438    close,
1439    buffer_size_range=(DEFAULT_BUFFER_SIZE, DEFAULT_BUFFER_SIZE, 0),
1440    k_range=(DEFAULT_K, DEFAULT_K, 0),
1441    percentile_range=(DEFAULT_PERCENTILE, DEFAULT_PERCENTILE, 0.0),
1442    smooth_range=(DEFAULT_SMOOTH, DEFAULT_SMOOTH, 0),
1443    kernel=None
1444))]
1445pub fn neighboring_trailing_stop_batch_py<'py>(
1446    py: Python<'py>,
1447    high: PyReadonlyArray1<'py, f64>,
1448    low: PyReadonlyArray1<'py, f64>,
1449    close: PyReadonlyArray1<'py, f64>,
1450    buffer_size_range: (usize, usize, usize),
1451    k_range: (usize, usize, usize),
1452    percentile_range: (f64, f64, f64),
1453    smooth_range: (usize, usize, usize),
1454    kernel: Option<&str>,
1455) -> PyResult<Bound<'py, PyDict>> {
1456    let high = high.as_slice()?;
1457    let low = low.as_slice()?;
1458    let close = close.as_slice()?;
1459    let kernel = validate_kernel(kernel, true)?;
1460    let sweep = NeighboringTrailingStopBatchRange {
1461        buffer_size: buffer_size_range,
1462        k: k_range,
1463        percentile: percentile_range,
1464        smooth: smooth_range,
1465    };
1466    let combos = expand_grid_neighboring_trailing_stop(&sweep)
1467        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1468    let rows = combos.len();
1469    let cols = close.len();
1470    let total = rows
1471        .checked_mul(cols)
1472        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
1473
1474    let trailing_stop_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1475    let bullish_band_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1476    let bearish_band_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1477    let direction_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1478    let discovery_bull_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1479    let discovery_bear_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1480
1481    let trailing_stop_slice = unsafe { trailing_stop_arr.as_slice_mut()? };
1482    let bullish_band_slice = unsafe { bullish_band_arr.as_slice_mut()? };
1483    let bearish_band_slice = unsafe { bearish_band_arr.as_slice_mut()? };
1484    let direction_slice = unsafe { direction_arr.as_slice_mut()? };
1485    let discovery_bull_slice = unsafe { discovery_bull_arr.as_slice_mut()? };
1486    let discovery_bear_slice = unsafe { discovery_bear_arr.as_slice_mut()? };
1487
1488    let combos = py
1489        .allow_threads(|| {
1490            let batch_kernel = match kernel {
1491                Kernel::Auto => detect_best_batch_kernel(),
1492                other => other,
1493            };
1494            neighboring_trailing_stop_batch_inner_into(
1495                high,
1496                low,
1497                close,
1498                &sweep,
1499                batch_kernel.to_non_batch(),
1500                true,
1501                trailing_stop_slice,
1502                bullish_band_slice,
1503                bearish_band_slice,
1504                direction_slice,
1505                discovery_bull_slice,
1506                discovery_bear_slice,
1507            )
1508        })
1509        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1510
1511    let dict = PyDict::new(py);
1512    dict.set_item("trailing_stop", trailing_stop_arr.reshape((rows, cols))?)?;
1513    dict.set_item("bullish_band", bullish_band_arr.reshape((rows, cols))?)?;
1514    dict.set_item("bearish_band", bearish_band_arr.reshape((rows, cols))?)?;
1515    dict.set_item("direction", direction_arr.reshape((rows, cols))?)?;
1516    dict.set_item("discovery_bull", discovery_bull_arr.reshape((rows, cols))?)?;
1517    dict.set_item("discovery_bear", discovery_bear_arr.reshape((rows, cols))?)?;
1518    dict.set_item(
1519        "buffer_sizes",
1520        combos
1521            .iter()
1522            .map(|combo| combo.buffer_size.unwrap_or(DEFAULT_BUFFER_SIZE) as u64)
1523            .collect::<Vec<_>>()
1524            .into_pyarray(py),
1525    )?;
1526    dict.set_item(
1527        "ks",
1528        combos
1529            .iter()
1530            .map(|combo| combo.k.unwrap_or(DEFAULT_K) as u64)
1531            .collect::<Vec<_>>()
1532            .into_pyarray(py),
1533    )?;
1534    dict.set_item(
1535        "percentiles",
1536        combos
1537            .iter()
1538            .map(|combo| combo.percentile.unwrap_or(DEFAULT_PERCENTILE))
1539            .collect::<Vec<_>>()
1540            .into_pyarray(py),
1541    )?;
1542    dict.set_item(
1543        "smoothings",
1544        combos
1545            .iter()
1546            .map(|combo| combo.smooth.unwrap_or(DEFAULT_SMOOTH) as u64)
1547            .collect::<Vec<_>>()
1548            .into_pyarray(py),
1549    )?;
1550    dict.set_item("rows", rows)?;
1551    dict.set_item("cols", cols)?;
1552    Ok(dict)
1553}
1554
1555#[cfg(feature = "python")]
1556pub fn register_neighboring_trailing_stop_module(
1557    module: &Bound<'_, pyo3::types::PyModule>,
1558) -> PyResult<()> {
1559    module.add_function(wrap_pyfunction!(neighboring_trailing_stop_py, module)?)?;
1560    module.add_function(wrap_pyfunction!(
1561        neighboring_trailing_stop_batch_py,
1562        module
1563    )?)?;
1564    module.add_class::<NeighboringTrailingStopStreamPy>()?;
1565    Ok(())
1566}
1567
1568#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1569#[derive(Serialize, Deserialize)]
1570pub struct NeighboringTrailingStopJsOutput {
1571    pub trailing_stop: Vec<f64>,
1572    pub bullish_band: Vec<f64>,
1573    pub bearish_band: Vec<f64>,
1574    pub direction: Vec<f64>,
1575    pub discovery_bull: Vec<f64>,
1576    pub discovery_bear: Vec<f64>,
1577}
1578
1579#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1580#[wasm_bindgen(js_name = "neighboring_trailing_stop_js")]
1581pub fn neighboring_trailing_stop_js(
1582    high: &[f64],
1583    low: &[f64],
1584    close: &[f64],
1585    buffer_size: usize,
1586    k: usize,
1587    percentile: f64,
1588    smooth: usize,
1589) -> Result<JsValue, JsValue> {
1590    let input = NeighboringTrailingStopInput::from_slices(
1591        high,
1592        low,
1593        close,
1594        NeighboringTrailingStopParams {
1595            buffer_size: Some(buffer_size),
1596            k: Some(k),
1597            percentile: Some(percentile),
1598            smooth: Some(smooth),
1599        },
1600    );
1601    let out = neighboring_trailing_stop(&input).map_err(|e| JsValue::from_str(&e.to_string()))?;
1602    serde_wasm_bindgen::to_value(&NeighboringTrailingStopJsOutput {
1603        trailing_stop: out.trailing_stop,
1604        bullish_band: out.bullish_band,
1605        bearish_band: out.bearish_band,
1606        direction: out.direction,
1607        discovery_bull: out.discovery_bull,
1608        discovery_bear: out.discovery_bear,
1609    })
1610    .map_err(|e| JsValue::from_str(&e.to_string()))
1611}
1612
1613#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1614#[wasm_bindgen]
1615pub fn neighboring_trailing_stop_alloc(len: usize) -> *mut f64 {
1616    let mut vec = Vec::<f64>::with_capacity(len);
1617    let ptr = vec.as_mut_ptr();
1618    std::mem::forget(vec);
1619    ptr
1620}
1621
1622#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1623#[wasm_bindgen]
1624pub fn neighboring_trailing_stop_free(ptr: *mut f64, len: usize) {
1625    if !ptr.is_null() {
1626        unsafe {
1627            let _ = Vec::from_raw_parts(ptr, len, len);
1628        }
1629    }
1630}
1631
1632#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1633fn has_duplicate_ptrs(ptrs: &[usize]) -> bool {
1634    for i in 0..ptrs.len() {
1635        for j in (i + 1)..ptrs.len() {
1636            if ptrs[i] == ptrs[j] {
1637                return true;
1638            }
1639        }
1640    }
1641    false
1642}
1643
1644#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1645#[wasm_bindgen]
1646#[allow(clippy::too_many_arguments)]
1647pub fn neighboring_trailing_stop_into(
1648    high_ptr: *const f64,
1649    low_ptr: *const f64,
1650    close_ptr: *const f64,
1651    trailing_stop_ptr: *mut f64,
1652    bullish_band_ptr: *mut f64,
1653    bearish_band_ptr: *mut f64,
1654    direction_ptr: *mut f64,
1655    discovery_bull_ptr: *mut f64,
1656    discovery_bear_ptr: *mut f64,
1657    len: usize,
1658    buffer_size: usize,
1659    k: usize,
1660    percentile: f64,
1661    smooth: usize,
1662) -> Result<(), JsValue> {
1663    if high_ptr.is_null()
1664        || low_ptr.is_null()
1665        || close_ptr.is_null()
1666        || trailing_stop_ptr.is_null()
1667        || bullish_band_ptr.is_null()
1668        || bearish_band_ptr.is_null()
1669        || direction_ptr.is_null()
1670        || discovery_bull_ptr.is_null()
1671        || discovery_bear_ptr.is_null()
1672    {
1673        return Err(JsValue::from_str("Null pointer provided"));
1674    }
1675
1676    unsafe {
1677        let high = std::slice::from_raw_parts(high_ptr, len);
1678        let low = std::slice::from_raw_parts(low_ptr, len);
1679        let close = std::slice::from_raw_parts(close_ptr, len);
1680        let input = NeighboringTrailingStopInput::from_slices(
1681            high,
1682            low,
1683            close,
1684            NeighboringTrailingStopParams {
1685                buffer_size: Some(buffer_size),
1686                k: Some(k),
1687                percentile: Some(percentile),
1688                smooth: Some(smooth),
1689            },
1690        );
1691
1692        let output_ptrs = [
1693            trailing_stop_ptr as usize,
1694            bullish_band_ptr as usize,
1695            bearish_band_ptr as usize,
1696            direction_ptr as usize,
1697            discovery_bull_ptr as usize,
1698            discovery_bear_ptr as usize,
1699        ];
1700        let need_temp = output_ptrs.iter().any(|&ptr| {
1701            ptr == high_ptr as usize || ptr == low_ptr as usize || ptr == close_ptr as usize
1702        }) || has_duplicate_ptrs(&output_ptrs);
1703
1704        if need_temp {
1705            let mut trailing_stop = vec![0.0; len];
1706            let mut bullish_band = vec![0.0; len];
1707            let mut bearish_band = vec![0.0; len];
1708            let mut direction = vec![0.0; len];
1709            let mut discovery_bull = vec![0.0; len];
1710            let mut discovery_bear = vec![0.0; len];
1711            neighboring_trailing_stop_into_slices(
1712                &mut trailing_stop,
1713                &mut bullish_band,
1714                &mut bearish_band,
1715                &mut direction,
1716                &mut discovery_bull,
1717                &mut discovery_bear,
1718                &input,
1719                Kernel::Auto,
1720            )
1721            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1722            std::slice::from_raw_parts_mut(trailing_stop_ptr, len).copy_from_slice(&trailing_stop);
1723            std::slice::from_raw_parts_mut(bullish_band_ptr, len).copy_from_slice(&bullish_band);
1724            std::slice::from_raw_parts_mut(bearish_band_ptr, len).copy_from_slice(&bearish_band);
1725            std::slice::from_raw_parts_mut(direction_ptr, len).copy_from_slice(&direction);
1726            std::slice::from_raw_parts_mut(discovery_bull_ptr, len)
1727                .copy_from_slice(&discovery_bull);
1728            std::slice::from_raw_parts_mut(discovery_bear_ptr, len)
1729                .copy_from_slice(&discovery_bear);
1730        } else {
1731            neighboring_trailing_stop_into_slices(
1732                std::slice::from_raw_parts_mut(trailing_stop_ptr, len),
1733                std::slice::from_raw_parts_mut(bullish_band_ptr, len),
1734                std::slice::from_raw_parts_mut(bearish_band_ptr, len),
1735                std::slice::from_raw_parts_mut(direction_ptr, len),
1736                std::slice::from_raw_parts_mut(discovery_bull_ptr, len),
1737                std::slice::from_raw_parts_mut(discovery_bear_ptr, len),
1738                &input,
1739                Kernel::Auto,
1740            )
1741            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1742        }
1743    }
1744
1745    Ok(())
1746}
1747
1748#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1749#[derive(Serialize, Deserialize)]
1750pub struct NeighboringTrailingStopBatchJsConfig {
1751    pub buffer_size_range: Option<(usize, usize, usize)>,
1752    pub k_range: Option<(usize, usize, usize)>,
1753    pub percentile_range: Option<(f64, f64, f64)>,
1754    pub smooth_range: Option<(usize, usize, usize)>,
1755}
1756
1757#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1758#[derive(Serialize, Deserialize)]
1759pub struct NeighboringTrailingStopBatchJsOutput {
1760    pub trailing_stop: Vec<f64>,
1761    pub bullish_band: Vec<f64>,
1762    pub bearish_band: Vec<f64>,
1763    pub direction: Vec<f64>,
1764    pub discovery_bull: Vec<f64>,
1765    pub discovery_bear: Vec<f64>,
1766    pub combos: Vec<NeighboringTrailingStopParams>,
1767    pub buffer_sizes: Vec<usize>,
1768    pub ks: Vec<usize>,
1769    pub percentiles: Vec<f64>,
1770    pub smoothings: Vec<usize>,
1771    pub rows: usize,
1772    pub cols: usize,
1773}
1774
1775#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1776#[wasm_bindgen(js_name = "neighboring_trailing_stop_batch_js")]
1777pub fn neighboring_trailing_stop_batch_js(
1778    high: &[f64],
1779    low: &[f64],
1780    close: &[f64],
1781    config: JsValue,
1782) -> Result<JsValue, JsValue> {
1783    let config: NeighboringTrailingStopBatchJsConfig = serde_wasm_bindgen::from_value(config)
1784        .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
1785    let sweep = NeighboringTrailingStopBatchRange {
1786        buffer_size: config.buffer_size_range.unwrap_or((
1787            DEFAULT_BUFFER_SIZE,
1788            DEFAULT_BUFFER_SIZE,
1789            0,
1790        )),
1791        k: config.k_range.unwrap_or((DEFAULT_K, DEFAULT_K, 0)),
1792        percentile: config.percentile_range.unwrap_or((
1793            DEFAULT_PERCENTILE,
1794            DEFAULT_PERCENTILE,
1795            0.0,
1796        )),
1797        smooth: config
1798            .smooth_range
1799            .unwrap_or((DEFAULT_SMOOTH, DEFAULT_SMOOTH, 0)),
1800    };
1801    let output =
1802        neighboring_trailing_stop_batch_inner(high, low, close, &sweep, Kernel::Auto, false)
1803            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1804    serde_wasm_bindgen::to_value(&NeighboringTrailingStopBatchJsOutput {
1805        trailing_stop: output.trailing_stop,
1806        bullish_band: output.bullish_band,
1807        bearish_band: output.bearish_band,
1808        direction: output.direction,
1809        discovery_bull: output.discovery_bull,
1810        discovery_bear: output.discovery_bear,
1811        buffer_sizes: output
1812            .combos
1813            .iter()
1814            .map(|combo| combo.buffer_size.unwrap_or(DEFAULT_BUFFER_SIZE))
1815            .collect(),
1816        ks: output
1817            .combos
1818            .iter()
1819            .map(|combo| combo.k.unwrap_or(DEFAULT_K))
1820            .collect(),
1821        percentiles: output
1822            .combos
1823            .iter()
1824            .map(|combo| combo.percentile.unwrap_or(DEFAULT_PERCENTILE))
1825            .collect(),
1826        smoothings: output
1827            .combos
1828            .iter()
1829            .map(|combo| combo.smooth.unwrap_or(DEFAULT_SMOOTH))
1830            .collect(),
1831        combos: output.combos,
1832        rows: output.rows,
1833        cols: output.cols,
1834    })
1835    .map_err(|e| JsValue::from_str(&e.to_string()))
1836}
1837
1838#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1839#[wasm_bindgen]
1840#[allow(clippy::too_many_arguments)]
1841pub fn neighboring_trailing_stop_batch_into(
1842    high_ptr: *const f64,
1843    low_ptr: *const f64,
1844    close_ptr: *const f64,
1845    trailing_stop_ptr: *mut f64,
1846    bullish_band_ptr: *mut f64,
1847    bearish_band_ptr: *mut f64,
1848    direction_ptr: *mut f64,
1849    discovery_bull_ptr: *mut f64,
1850    discovery_bear_ptr: *mut f64,
1851    len: usize,
1852    buffer_size_start: usize,
1853    buffer_size_end: usize,
1854    buffer_size_step: usize,
1855    k_start: usize,
1856    k_end: usize,
1857    k_step: usize,
1858    percentile_start: f64,
1859    percentile_end: f64,
1860    percentile_step: f64,
1861    smooth_start: usize,
1862    smooth_end: usize,
1863    smooth_step: usize,
1864) -> Result<usize, JsValue> {
1865    if high_ptr.is_null()
1866        || low_ptr.is_null()
1867        || close_ptr.is_null()
1868        || trailing_stop_ptr.is_null()
1869        || bullish_band_ptr.is_null()
1870        || bearish_band_ptr.is_null()
1871        || direction_ptr.is_null()
1872        || discovery_bull_ptr.is_null()
1873        || discovery_bear_ptr.is_null()
1874    {
1875        return Err(JsValue::from_str("Null pointer provided"));
1876    }
1877
1878    let sweep = NeighboringTrailingStopBatchRange {
1879        buffer_size: (buffer_size_start, buffer_size_end, buffer_size_step),
1880        k: (k_start, k_end, k_step),
1881        percentile: (percentile_start, percentile_end, percentile_step),
1882        smooth: (smooth_start, smooth_end, smooth_step),
1883    };
1884    let combos = expand_grid_neighboring_trailing_stop(&sweep)
1885        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1886    let rows = combos.len();
1887    let total = rows
1888        .checked_mul(len)
1889        .ok_or_else(|| JsValue::from_str("rows*cols overflow"))?;
1890
1891    unsafe {
1892        let high = std::slice::from_raw_parts(high_ptr, len);
1893        let low = std::slice::from_raw_parts(low_ptr, len);
1894        let close = std::slice::from_raw_parts(close_ptr, len);
1895        neighboring_trailing_stop_batch_inner_into(
1896            high,
1897            low,
1898            close,
1899            &sweep,
1900            Kernel::Auto,
1901            false,
1902            std::slice::from_raw_parts_mut(trailing_stop_ptr, total),
1903            std::slice::from_raw_parts_mut(bullish_band_ptr, total),
1904            std::slice::from_raw_parts_mut(bearish_band_ptr, total),
1905            std::slice::from_raw_parts_mut(direction_ptr, total),
1906            std::slice::from_raw_parts_mut(discovery_bull_ptr, total),
1907            std::slice::from_raw_parts_mut(discovery_bear_ptr, total),
1908        )
1909        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1910    }
1911
1912    Ok(rows)
1913}
1914
1915#[cfg(test)]
1916mod tests {
1917    use super::*;
1918
1919    fn sample_ohlc(length: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
1920        let mut high = Vec::with_capacity(length);
1921        let mut low = Vec::with_capacity(length);
1922        let mut close = Vec::with_capacity(length);
1923        for i in 0..length {
1924            let x = i as f64;
1925            let open = 100.0 + x * 0.04 + (x * 0.07).sin();
1926            let c = open + (x * 0.11).cos() * 0.85;
1927            high.push(open.max(c) + 0.55 + (x * 0.03).sin().abs() * 0.2);
1928            low.push(open.min(c) - 0.55 - (x * 0.05).cos().abs() * 0.2);
1929            close.push(c);
1930        }
1931        (high, low, close)
1932    }
1933
1934    fn assert_series_eq(left: &[f64], right: &[f64], tol: f64) {
1935        assert_eq!(left.len(), right.len());
1936        for (a, b) in left.iter().zip(right.iter()) {
1937            if a.is_nan() && b.is_nan() {
1938                continue;
1939            }
1940            assert!((*a - *b).abs() <= tol, "left={a}, right={b}");
1941        }
1942    }
1943
1944    #[test]
1945    fn neighboring_trailing_stop_output_contract() -> Result<(), Box<dyn std::error::Error>> {
1946        let (high, low, close) = sample_ohlc(256);
1947        let input = NeighboringTrailingStopInput::from_slices(
1948            &high,
1949            &low,
1950            &close,
1951            NeighboringTrailingStopParams::default(),
1952        );
1953        let out = neighboring_trailing_stop_with_kernel(&input, Kernel::Scalar)?;
1954        assert_eq!(out.trailing_stop.len(), close.len());
1955        assert_eq!(out.bullish_band.len(), close.len());
1956        assert_eq!(out.bearish_band.len(), close.len());
1957        assert_eq!(out.direction.len(), close.len());
1958        assert_eq!(out.discovery_bull.len(), close.len());
1959        assert_eq!(out.discovery_bear.len(), close.len());
1960        assert!(out.direction.iter().any(|v| *v == 1.0 || *v == -1.0));
1961        Ok(())
1962    }
1963
1964    #[test]
1965    fn neighboring_trailing_stop_rejects_invalid_params() {
1966        let (high, low, close) = sample_ohlc(64);
1967        let err = neighboring_trailing_stop_with_kernel(
1968            &NeighboringTrailingStopInput::from_slices(
1969                &high,
1970                &low,
1971                &close,
1972                NeighboringTrailingStopParams {
1973                    buffer_size: Some(50),
1974                    k: Some(DEFAULT_K),
1975                    percentile: Some(DEFAULT_PERCENTILE),
1976                    smooth: Some(DEFAULT_SMOOTH),
1977                },
1978            ),
1979            Kernel::Scalar,
1980        )
1981        .unwrap_err();
1982        assert!(matches!(
1983            err,
1984            NeighboringTrailingStopError::InvalidBufferSize { .. }
1985        ));
1986
1987        let err = neighboring_trailing_stop_with_kernel(
1988            &NeighboringTrailingStopInput::from_slices(
1989                &high,
1990                &low,
1991                &close,
1992                NeighboringTrailingStopParams {
1993                    buffer_size: Some(DEFAULT_BUFFER_SIZE),
1994                    k: Some(2),
1995                    percentile: Some(DEFAULT_PERCENTILE),
1996                    smooth: Some(DEFAULT_SMOOTH),
1997                },
1998            ),
1999            Kernel::Scalar,
2000        )
2001        .unwrap_err();
2002        assert!(matches!(err, NeighboringTrailingStopError::InvalidK { .. }));
2003    }
2004
2005    #[test]
2006    fn neighboring_trailing_stop_builder_matches_direct() -> Result<(), Box<dyn std::error::Error>>
2007    {
2008        let (high, low, close) = sample_ohlc(220);
2009        let direct = neighboring_trailing_stop_with_kernel(
2010            &NeighboringTrailingStopInput::from_slices(
2011                &high,
2012                &low,
2013                &close,
2014                NeighboringTrailingStopParams {
2015                    buffer_size: Some(180),
2016                    k: Some(30),
2017                    percentile: Some(87.5),
2018                    smooth: Some(4),
2019                },
2020            ),
2021            Kernel::Scalar,
2022        )?;
2023        let built = NeighboringTrailingStopBuilder::new()
2024            .buffer_size(180)
2025            .k(30)
2026            .percentile(87.5)
2027            .smooth(4)
2028            .kernel(Kernel::Scalar)
2029            .apply_slices(&high, &low, &close)?;
2030        assert_series_eq(&direct.trailing_stop, &built.trailing_stop, 1e-12);
2031        assert_series_eq(&direct.bullish_band, &built.bullish_band, 1e-12);
2032        assert_series_eq(&direct.bearish_band, &built.bearish_band, 1e-12);
2033        Ok(())
2034    }
2035
2036    #[test]
2037    fn neighboring_trailing_stop_stream_matches_batch_with_reset(
2038    ) -> Result<(), Box<dyn std::error::Error>> {
2039        let (mut high, mut low, mut close) = sample_ohlc(240);
2040        high[120] = f64::NAN;
2041        low[120] = f64::NAN;
2042        close[120] = f64::NAN;
2043
2044        let params = NeighboringTrailingStopParams {
2045            buffer_size: Some(180),
2046            k: Some(25),
2047            percentile: Some(92.0),
2048            smooth: Some(4),
2049        };
2050        let batch = neighboring_trailing_stop_with_kernel(
2051            &NeighboringTrailingStopInput::from_slices(&high, &low, &close, params.clone()),
2052            Kernel::Scalar,
2053        )?;
2054        let mut stream = NeighboringTrailingStopStream::try_new(params)?;
2055
2056        let mut trailing_stop = Vec::with_capacity(close.len());
2057        let mut bullish_band = Vec::with_capacity(close.len());
2058        let mut bearish_band = Vec::with_capacity(close.len());
2059        let mut direction = Vec::with_capacity(close.len());
2060        let mut discovery_bull = Vec::with_capacity(close.len());
2061        let mut discovery_bear = Vec::with_capacity(close.len());
2062
2063        for i in 0..close.len() {
2064            if let Some(point) = stream.update(high[i], low[i], close[i]) {
2065                trailing_stop.push(point.trailing_stop);
2066                bullish_band.push(point.bullish_band);
2067                bearish_band.push(point.bearish_band);
2068                direction.push(point.direction);
2069                discovery_bull.push(point.discovery_bull);
2070                discovery_bear.push(point.discovery_bear);
2071            } else {
2072                trailing_stop.push(f64::NAN);
2073                bullish_band.push(f64::NAN);
2074                bearish_band.push(f64::NAN);
2075                direction.push(f64::NAN);
2076                discovery_bull.push(f64::NAN);
2077                discovery_bear.push(f64::NAN);
2078            }
2079        }
2080
2081        assert_eq!(stream.get_warmup_period(), 0);
2082        assert_series_eq(&trailing_stop, &batch.trailing_stop, 1e-12);
2083        assert_series_eq(&bullish_band, &batch.bullish_band, 1e-12);
2084        assert_series_eq(&bearish_band, &batch.bearish_band, 1e-12);
2085        assert_series_eq(&direction, &batch.direction, 1e-12);
2086        assert_series_eq(&discovery_bull, &batch.discovery_bull, 1e-12);
2087        assert_series_eq(&discovery_bear, &batch.discovery_bear, 1e-12);
2088        Ok(())
2089    }
2090
2091    #[test]
2092    fn neighboring_trailing_stop_into_matches_api() -> Result<(), Box<dyn std::error::Error>> {
2093        let (high, low, close) = sample_ohlc(180);
2094        let input = NeighboringTrailingStopInput::from_slices(
2095            &high,
2096            &low,
2097            &close,
2098            NeighboringTrailingStopParams {
2099                buffer_size: Some(160),
2100                k: Some(20),
2101                percentile: Some(88.0),
2102                smooth: Some(3),
2103            },
2104        );
2105        let api = neighboring_trailing_stop_with_kernel(&input, Kernel::Scalar)?;
2106        let mut trailing_stop = vec![0.0; close.len()];
2107        let mut bullish_band = vec![0.0; close.len()];
2108        let mut bearish_band = vec![0.0; close.len()];
2109        let mut direction = vec![0.0; close.len()];
2110        let mut discovery_bull = vec![0.0; close.len()];
2111        let mut discovery_bear = vec![0.0; close.len()];
2112        neighboring_trailing_stop_into_slices(
2113            &mut trailing_stop,
2114            &mut bullish_band,
2115            &mut bearish_band,
2116            &mut direction,
2117            &mut discovery_bull,
2118            &mut discovery_bear,
2119            &input,
2120            Kernel::Scalar,
2121        )?;
2122        assert_series_eq(&trailing_stop, &api.trailing_stop, 1e-12);
2123        assert_series_eq(&bullish_band, &api.bullish_band, 1e-12);
2124        assert_series_eq(&bearish_band, &api.bearish_band, 1e-12);
2125        assert_series_eq(&direction, &api.direction, 1e-12);
2126        assert_series_eq(&discovery_bull, &api.discovery_bull, 1e-12);
2127        assert_series_eq(&discovery_bear, &api.discovery_bear, 1e-12);
2128        Ok(())
2129    }
2130
2131    #[test]
2132    fn neighboring_trailing_stop_batch_single_param_matches_single(
2133    ) -> Result<(), Box<dyn std::error::Error>> {
2134        let (high, low, close) = sample_ohlc(160);
2135        let single = neighboring_trailing_stop_with_kernel(
2136            &NeighboringTrailingStopInput::from_slices(
2137                &high,
2138                &low,
2139                &close,
2140                NeighboringTrailingStopParams {
2141                    buffer_size: Some(180),
2142                    k: Some(35),
2143                    percentile: Some(91.0),
2144                    smooth: Some(4),
2145                },
2146            ),
2147            Kernel::Scalar,
2148        )?;
2149        let batch = neighboring_trailing_stop_batch_with_kernel(
2150            &high,
2151            &low,
2152            &close,
2153            &NeighboringTrailingStopBatchRange {
2154                buffer_size: (180, 180, 0),
2155                k: (35, 35, 0),
2156                percentile: (91.0, 91.0, 0.0),
2157                smooth: (4, 4, 0),
2158            },
2159            Kernel::ScalarBatch,
2160        )?;
2161        assert_eq!(batch.rows, 1);
2162        assert_eq!(batch.cols, close.len());
2163        let row = batch.row_slices(0).unwrap();
2164        assert_series_eq(row.0, single.trailing_stop.as_slice(), 1e-12);
2165        assert_series_eq(row.1, single.bullish_band.as_slice(), 1e-12);
2166        assert_series_eq(row.2, single.bearish_band.as_slice(), 1e-12);
2167        Ok(())
2168    }
2169
2170    #[test]
2171    fn neighboring_trailing_stop_batch_metadata() -> Result<(), Box<dyn std::error::Error>> {
2172        let (high, low, close) = sample_ohlc(96);
2173        let batch = neighboring_trailing_stop_batch_with_kernel(
2174            &high,
2175            &low,
2176            &close,
2177            &NeighboringTrailingStopBatchRange {
2178                buffer_size: (150, 150, 0),
2179                k: (20, 24, 4),
2180                percentile: (85.0, 90.0, 5.0),
2181                smooth: (3, 3, 0),
2182            },
2183            Kernel::ScalarBatch,
2184        )?;
2185        assert_eq!(batch.rows, 4);
2186        assert_eq!(batch.cols, close.len());
2187        assert_eq!(batch.combos[0].buffer_size, Some(150));
2188        assert_eq!(batch.combos[0].k, Some(20));
2189        assert_eq!(batch.combos[1].percentile, Some(90.0));
2190        Ok(())
2191    }
2192}