Skip to main content

vector_ta/indicators/
trend_trigger_factor.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::Candles;
16use crate::utilities::enums::Kernel;
17use crate::utilities::helpers::{
18    alloc_with_nan_prefix, detect_best_batch_kernel, init_matrix_prefixes, make_uninit_matrix,
19};
20#[cfg(feature = "python")]
21use crate::utilities::kernel_validation::validate_kernel;
22#[cfg(not(target_arch = "wasm32"))]
23use rayon::prelude::*;
24use std::collections::VecDeque;
25use std::mem::ManuallyDrop;
26use thiserror::Error;
27
28const DEFAULT_LENGTH: usize = 15;
29
30#[derive(Debug, Clone)]
31pub enum TrendTriggerFactorData<'a> {
32    Candles { candles: &'a Candles },
33    Slices { high: &'a [f64], low: &'a [f64] },
34}
35
36#[derive(Debug, Clone)]
37pub struct TrendTriggerFactorOutput {
38    pub values: Vec<f64>,
39}
40
41#[derive(Debug, Clone)]
42#[cfg_attr(
43    all(target_arch = "wasm32", feature = "wasm"),
44    derive(Serialize, Deserialize)
45)]
46pub struct TrendTriggerFactorParams {
47    pub length: Option<usize>,
48}
49
50impl Default for TrendTriggerFactorParams {
51    fn default() -> Self {
52        Self {
53            length: Some(DEFAULT_LENGTH),
54        }
55    }
56}
57
58#[derive(Debug, Clone)]
59pub struct TrendTriggerFactorInput<'a> {
60    pub data: TrendTriggerFactorData<'a>,
61    pub params: TrendTriggerFactorParams,
62}
63
64impl<'a> TrendTriggerFactorInput<'a> {
65    #[inline]
66    pub fn from_candles(candles: &'a Candles, params: TrendTriggerFactorParams) -> Self {
67        Self {
68            data: TrendTriggerFactorData::Candles { candles },
69            params,
70        }
71    }
72
73    #[inline]
74    pub fn from_slices(high: &'a [f64], low: &'a [f64], params: TrendTriggerFactorParams) -> Self {
75        Self {
76            data: TrendTriggerFactorData::Slices { high, low },
77            params,
78        }
79    }
80
81    #[inline]
82    pub fn with_default_candles(candles: &'a Candles) -> Self {
83        Self::from_candles(candles, TrendTriggerFactorParams::default())
84    }
85
86    #[inline]
87    pub fn get_length(&self) -> usize {
88        self.params.length.unwrap_or(DEFAULT_LENGTH)
89    }
90}
91
92#[derive(Copy, Clone, Debug)]
93pub struct TrendTriggerFactorBuilder {
94    length: Option<usize>,
95    kernel: Kernel,
96}
97
98impl Default for TrendTriggerFactorBuilder {
99    fn default() -> Self {
100        Self {
101            length: None,
102            kernel: Kernel::Auto,
103        }
104    }
105}
106
107impl TrendTriggerFactorBuilder {
108    #[inline(always)]
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    #[inline(always)]
114    pub fn length(mut self, value: usize) -> Self {
115        self.length = Some(value);
116        self
117    }
118
119    #[inline(always)]
120    pub fn kernel(mut self, value: Kernel) -> Self {
121        self.kernel = value;
122        self
123    }
124
125    #[inline(always)]
126    pub fn apply(
127        self,
128        candles: &Candles,
129    ) -> Result<TrendTriggerFactorOutput, TrendTriggerFactorError> {
130        let input = TrendTriggerFactorInput::from_candles(
131            candles,
132            TrendTriggerFactorParams {
133                length: self.length,
134            },
135        );
136        trend_trigger_factor_with_kernel(&input, self.kernel)
137    }
138
139    #[inline(always)]
140    pub fn apply_slices(
141        self,
142        high: &[f64],
143        low: &[f64],
144    ) -> Result<TrendTriggerFactorOutput, TrendTriggerFactorError> {
145        let input = TrendTriggerFactorInput::from_slices(
146            high,
147            low,
148            TrendTriggerFactorParams {
149                length: self.length,
150            },
151        );
152        trend_trigger_factor_with_kernel(&input, self.kernel)
153    }
154
155    #[inline(always)]
156    pub fn into_stream(self) -> Result<TrendTriggerFactorStream, TrendTriggerFactorError> {
157        TrendTriggerFactorStream::try_new(TrendTriggerFactorParams {
158            length: self.length,
159        })
160    }
161}
162
163#[derive(Debug, Error)]
164pub enum TrendTriggerFactorError {
165    #[error("trend_trigger_factor: Input data slice is empty.")]
166    EmptyInputData,
167    #[error("trend_trigger_factor: All values are NaN.")]
168    AllValuesNaN,
169    #[error("trend_trigger_factor: Inconsistent slice lengths: high={high_len}, low={low_len}")]
170    InconsistentSliceLengths { high_len: usize, low_len: usize },
171    #[error("trend_trigger_factor: Invalid length: length={length}, data length={data_len}")]
172    InvalidLength { length: usize, data_len: usize },
173    #[error("trend_trigger_factor: Not enough valid data: needed={needed}, valid={valid}")]
174    NotEnoughValidData { needed: usize, valid: usize },
175    #[error("trend_trigger_factor: Output length mismatch: expected={expected}, got={got}")]
176    OutputLengthMismatch { expected: usize, got: usize },
177    #[error("trend_trigger_factor: Invalid range: start={start}, end={end}, step={step}")]
178    InvalidRange {
179        start: String,
180        end: String,
181        step: String,
182    },
183    #[error("trend_trigger_factor: Invalid kernel for batch: {0:?}")]
184    InvalidKernelForBatch(Kernel),
185}
186
187#[inline(always)]
188fn extract_high_low<'a>(
189    input: &'a TrendTriggerFactorInput<'a>,
190) -> Result<(&'a [f64], &'a [f64]), TrendTriggerFactorError> {
191    let (high, low) = match &input.data {
192        TrendTriggerFactorData::Candles { candles } => {
193            (candles.high.as_slice(), candles.low.as_slice())
194        }
195        TrendTriggerFactorData::Slices { high, low } => (*high, *low),
196    };
197
198    if high.is_empty() || low.is_empty() {
199        return Err(TrendTriggerFactorError::EmptyInputData);
200    }
201    if high.len() != low.len() {
202        return Err(TrendTriggerFactorError::InconsistentSliceLengths {
203            high_len: high.len(),
204            low_len: low.len(),
205        });
206    }
207    Ok((high, low))
208}
209
210#[inline(always)]
211fn first_valid_high_low(high: &[f64], low: &[f64]) -> Option<usize> {
212    (0..high.len()).find(|&i| high[i].is_finite() && low[i].is_finite())
213}
214
215#[inline(always)]
216fn prepare<'a>(
217    input: &'a TrendTriggerFactorInput<'a>,
218    kernel: Kernel,
219) -> Result<(&'a [f64], &'a [f64], usize, usize, Kernel), TrendTriggerFactorError> {
220    let (high, low) = extract_high_low(input)?;
221    let len = high.len();
222    let length = input.get_length();
223    if length == 0 || length > len {
224        return Err(TrendTriggerFactorError::InvalidLength {
225            length,
226            data_len: len,
227        });
228    }
229    let first = first_valid_high_low(high, low).ok_or(TrendTriggerFactorError::AllValuesNaN)?;
230    let valid = len.saturating_sub(first);
231    if valid < length {
232        return Err(TrendTriggerFactorError::NotEnoughValidData {
233            needed: length,
234            valid,
235        });
236    }
237    Ok((high, low, length, first, kernel.to_non_batch()))
238}
239
240#[inline(always)]
241fn calc_ttf(hh: f64, ll: f64, hist_hh: f64, hist_ll: f64) -> f64 {
242    let buy_power = hh - hist_ll;
243    let sell_power = hist_hh - ll;
244    let denom = buy_power + sell_power;
245    if denom.is_finite() && denom != 0.0 {
246        200.0 * (buy_power - sell_power) / denom
247    } else {
248        f64::NAN
249    }
250}
251
252#[inline(always)]
253fn compute_trend_trigger_factor_into(
254    high: &[f64],
255    low: &[f64],
256    length: usize,
257    first: usize,
258    out: &mut [f64],
259) {
260    let warm = first + length - 1;
261    let mut maxq: VecDeque<usize> = VecDeque::with_capacity(length + 1);
262    let mut minq: VecDeque<usize> = VecDeque::with_capacity(length + 1);
263    let mut hh_history: VecDeque<f64> = VecDeque::with_capacity(length + 1);
264    let mut ll_history: VecDeque<f64> = VecDeque::with_capacity(length + 1);
265
266    for i in first..high.len() {
267        let h = high[i];
268        let l = low[i];
269        if !h.is_finite() || !l.is_finite() {
270            if i >= warm {
271                out[i] = f64::NAN;
272            }
273            continue;
274        }
275
276        let window_start = i.saturating_add(1).saturating_sub(length).max(first);
277
278        while let Some(&front) = maxq.front() {
279            if front < window_start {
280                maxq.pop_front();
281            } else {
282                break;
283            }
284        }
285        while let Some(&front) = minq.front() {
286            if front < window_start {
287                minq.pop_front();
288            } else {
289                break;
290            }
291        }
292
293        while let Some(&back) = maxq.back() {
294            if high[back] <= h {
295                maxq.pop_back();
296            } else {
297                break;
298            }
299        }
300        maxq.push_back(i);
301
302        while let Some(&back) = minq.back() {
303            if low[back] >= l {
304                minq.pop_back();
305            } else {
306                break;
307            }
308        }
309        minq.push_back(i);
310
311        if i >= warm {
312            let hh = high[*maxq.front().unwrap()];
313            let ll = low[*minq.front().unwrap()];
314            let hist_hh = if hh_history.len() == length {
315                hh_history.front().copied().unwrap_or(0.0)
316            } else {
317                0.0
318            };
319            let hist_ll = if ll_history.len() == length {
320                ll_history.front().copied().unwrap_or(0.0)
321            } else {
322                0.0
323            };
324            out[i] = calc_ttf(hh, ll, hist_hh, hist_ll);
325
326            hh_history.push_back(hh);
327            ll_history.push_back(ll);
328            if hh_history.len() > length {
329                hh_history.pop_front();
330            }
331            if ll_history.len() > length {
332                ll_history.pop_front();
333            }
334        }
335    }
336}
337
338#[inline]
339pub fn trend_trigger_factor(
340    input: &TrendTriggerFactorInput,
341) -> Result<TrendTriggerFactorOutput, TrendTriggerFactorError> {
342    trend_trigger_factor_with_kernel(input, Kernel::Auto)
343}
344
345#[inline]
346pub fn trend_trigger_factor_with_kernel(
347    input: &TrendTriggerFactorInput,
348    kernel: Kernel,
349) -> Result<TrendTriggerFactorOutput, TrendTriggerFactorError> {
350    let (high, low, length, first, chosen) = prepare(input, kernel)?;
351    let _ = chosen;
352    let warm = first + length - 1;
353    let mut out = alloc_with_nan_prefix(high.len(), warm);
354    compute_trend_trigger_factor_into(high, low, length, first, &mut out);
355    Ok(TrendTriggerFactorOutput { values: out })
356}
357
358#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
359#[inline]
360pub fn trend_trigger_factor_into(
361    input: &TrendTriggerFactorInput,
362    out: &mut [f64],
363) -> Result<(), TrendTriggerFactorError> {
364    trend_trigger_factor_into_slice(out, input, Kernel::Auto)
365}
366
367#[inline]
368pub fn trend_trigger_factor_into_slice(
369    out: &mut [f64],
370    input: &TrendTriggerFactorInput,
371    kernel: Kernel,
372) -> Result<(), TrendTriggerFactorError> {
373    let (high, low, length, first, chosen) = prepare(input, kernel)?;
374    let _ = chosen;
375    if out.len() != high.len() {
376        return Err(TrendTriggerFactorError::OutputLengthMismatch {
377            expected: high.len(),
378            got: out.len(),
379        });
380    }
381    out.fill(f64::NAN);
382    compute_trend_trigger_factor_into(high, low, length, first, out);
383    Ok(())
384}
385
386#[derive(Debug, Clone)]
387pub struct TrendTriggerFactorStream {
388    length: usize,
389    index: usize,
390    maxq: VecDeque<(usize, f64)>,
391    minq: VecDeque<(usize, f64)>,
392    hh_history: VecDeque<f64>,
393    ll_history: VecDeque<f64>,
394}
395
396impl TrendTriggerFactorStream {
397    pub fn try_new(params: TrendTriggerFactorParams) -> Result<Self, TrendTriggerFactorError> {
398        let length = params.length.unwrap_or(DEFAULT_LENGTH);
399        if length == 0 {
400            return Err(TrendTriggerFactorError::InvalidLength {
401                length,
402                data_len: 0,
403            });
404        }
405        Ok(Self {
406            length,
407            index: 0,
408            maxq: VecDeque::with_capacity(length + 1),
409            minq: VecDeque::with_capacity(length + 1),
410            hh_history: VecDeque::with_capacity(length + 1),
411            ll_history: VecDeque::with_capacity(length + 1),
412        })
413    }
414
415    #[inline]
416    pub fn update(&mut self, high: f64, low: f64) -> f64 {
417        let idx = self.index;
418        self.index = self.index.saturating_add(1);
419
420        if !high.is_finite() || !low.is_finite() {
421            return f64::NAN;
422        }
423
424        let window_start = idx.saturating_add(1).saturating_sub(self.length);
425
426        while let Some(&(front_idx, _)) = self.maxq.front() {
427            if front_idx < window_start {
428                self.maxq.pop_front();
429            } else {
430                break;
431            }
432        }
433        while let Some(&(front_idx, _)) = self.minq.front() {
434            if front_idx < window_start {
435                self.minq.pop_front();
436            } else {
437                break;
438            }
439        }
440
441        while let Some(&(_, back_val)) = self.maxq.back() {
442            if back_val <= high {
443                self.maxq.pop_back();
444            } else {
445                break;
446            }
447        }
448        self.maxq.push_back((idx, high));
449
450        while let Some(&(_, back_val)) = self.minq.back() {
451            if back_val >= low {
452                self.minq.pop_back();
453            } else {
454                break;
455            }
456        }
457        self.minq.push_back((idx, low));
458
459        if idx + 1 < self.length {
460            return f64::NAN;
461        }
462
463        let hh = self.maxq.front().map(|(_, v)| *v).unwrap_or(high);
464        let ll = self.minq.front().map(|(_, v)| *v).unwrap_or(low);
465        let hist_hh = if self.hh_history.len() == self.length {
466            self.hh_history.front().copied().unwrap_or(0.0)
467        } else {
468            0.0
469        };
470        let hist_ll = if self.ll_history.len() == self.length {
471            self.ll_history.front().copied().unwrap_or(0.0)
472        } else {
473            0.0
474        };
475        let out = calc_ttf(hh, ll, hist_hh, hist_ll);
476
477        self.hh_history.push_back(hh);
478        self.ll_history.push_back(ll);
479        if self.hh_history.len() > self.length {
480            self.hh_history.pop_front();
481        }
482        if self.ll_history.len() > self.length {
483            self.ll_history.pop_front();
484        }
485
486        out
487    }
488
489    #[inline]
490    pub fn get_warmup_period(&self) -> usize {
491        self.length.saturating_sub(1)
492    }
493}
494
495#[derive(Debug, Clone)]
496pub struct TrendTriggerFactorBatchRange {
497    pub length: (usize, usize, usize),
498}
499
500#[derive(Debug, Clone)]
501pub struct TrendTriggerFactorBatchOutput {
502    pub values: Vec<f64>,
503    pub combos: Vec<TrendTriggerFactorParams>,
504    pub rows: usize,
505    pub cols: usize,
506}
507
508#[derive(Copy, Clone, Debug)]
509pub struct TrendTriggerFactorBatchBuilder {
510    length: (usize, usize, usize),
511    kernel: Kernel,
512}
513
514impl Default for TrendTriggerFactorBatchBuilder {
515    fn default() -> Self {
516        Self {
517            length: (DEFAULT_LENGTH, DEFAULT_LENGTH, 0),
518            kernel: Kernel::Auto,
519        }
520    }
521}
522
523impl TrendTriggerFactorBatchBuilder {
524    #[inline(always)]
525    pub fn new() -> Self {
526        Self::default()
527    }
528
529    #[inline(always)]
530    pub fn length_range(mut self, value: (usize, usize, usize)) -> Self {
531        self.length = value;
532        self
533    }
534
535    #[inline(always)]
536    pub fn kernel(mut self, value: Kernel) -> Self {
537        self.kernel = value;
538        self
539    }
540
541    #[inline(always)]
542    pub fn apply_candles(
543        self,
544        candles: &Candles,
545    ) -> Result<TrendTriggerFactorBatchOutput, TrendTriggerFactorError> {
546        trend_trigger_factor_batch_with_kernel(
547            candles.high.as_slice(),
548            candles.low.as_slice(),
549            &TrendTriggerFactorBatchRange {
550                length: self.length,
551            },
552            self.kernel,
553        )
554    }
555
556    #[inline(always)]
557    pub fn apply_slices(
558        self,
559        high: &[f64],
560        low: &[f64],
561    ) -> Result<TrendTriggerFactorBatchOutput, TrendTriggerFactorError> {
562        trend_trigger_factor_batch_with_kernel(
563            high,
564            low,
565            &TrendTriggerFactorBatchRange {
566                length: self.length,
567            },
568            self.kernel,
569        )
570    }
571}
572
573pub fn expand_grid(
574    sweep: &TrendTriggerFactorBatchRange,
575) -> Result<Vec<TrendTriggerFactorParams>, TrendTriggerFactorError> {
576    let (start, end, step) = sweep.length;
577    if start == 0 {
578        return Err(TrendTriggerFactorError::InvalidRange {
579            start: start.to_string(),
580            end: end.to_string(),
581            step: step.to_string(),
582        });
583    }
584    let mut lengths = Vec::new();
585    if step == 0 {
586        if start != end {
587            return Err(TrendTriggerFactorError::InvalidRange {
588                start: start.to_string(),
589                end: end.to_string(),
590                step: step.to_string(),
591            });
592        }
593        lengths.push(start);
594    } else {
595        if start > end {
596            return Err(TrendTriggerFactorError::InvalidRange {
597                start: start.to_string(),
598                end: end.to_string(),
599                step: step.to_string(),
600            });
601        }
602        let mut current = start;
603        while current <= end {
604            lengths.push(current);
605            match current.checked_add(step) {
606                Some(next) => current = next,
607                None => break,
608            }
609        }
610    }
611
612    Ok(lengths
613        .into_iter()
614        .map(|length| TrendTriggerFactorParams {
615            length: Some(length),
616        })
617        .collect())
618}
619
620pub fn trend_trigger_factor_batch_with_kernel(
621    high: &[f64],
622    low: &[f64],
623    sweep: &TrendTriggerFactorBatchRange,
624    kernel: Kernel,
625) -> Result<TrendTriggerFactorBatchOutput, TrendTriggerFactorError> {
626    let batch_kernel = match kernel {
627        Kernel::Auto => detect_best_batch_kernel(),
628        other if other.is_batch() => other,
629        _ => return Err(TrendTriggerFactorError::InvalidKernelForBatch(kernel)),
630    };
631    trend_trigger_factor_batch_par_slice(high, low, sweep, batch_kernel.to_non_batch())
632}
633
634#[inline(always)]
635pub fn trend_trigger_factor_batch_slice(
636    high: &[f64],
637    low: &[f64],
638    sweep: &TrendTriggerFactorBatchRange,
639    kernel: Kernel,
640) -> Result<TrendTriggerFactorBatchOutput, TrendTriggerFactorError> {
641    trend_trigger_factor_batch_inner(high, low, sweep, kernel, false)
642}
643
644#[inline(always)]
645pub fn trend_trigger_factor_batch_par_slice(
646    high: &[f64],
647    low: &[f64],
648    sweep: &TrendTriggerFactorBatchRange,
649    kernel: Kernel,
650) -> Result<TrendTriggerFactorBatchOutput, TrendTriggerFactorError> {
651    trend_trigger_factor_batch_inner(high, low, sweep, kernel, true)
652}
653
654fn validate_raw_slices(high: &[f64], low: &[f64]) -> Result<usize, TrendTriggerFactorError> {
655    if high.is_empty() || low.is_empty() {
656        return Err(TrendTriggerFactorError::EmptyInputData);
657    }
658    if high.len() != low.len() {
659        return Err(TrendTriggerFactorError::InconsistentSliceLengths {
660            high_len: high.len(),
661            low_len: low.len(),
662        });
663    }
664    first_valid_high_low(high, low).ok_or(TrendTriggerFactorError::AllValuesNaN)
665}
666
667fn trend_trigger_factor_batch_inner(
668    high: &[f64],
669    low: &[f64],
670    sweep: &TrendTriggerFactorBatchRange,
671    kernel: Kernel,
672    parallel: bool,
673) -> Result<TrendTriggerFactorBatchOutput, TrendTriggerFactorError> {
674    let combos = expand_grid(sweep)?;
675    let first = validate_raw_slices(high, low)?;
676    let max_length = combos
677        .iter()
678        .map(|combo| combo.length.unwrap())
679        .max()
680        .unwrap();
681    let valid = high.len().saturating_sub(first);
682    if valid < max_length {
683        return Err(TrendTriggerFactorError::NotEnoughValidData {
684            needed: max_length,
685            valid,
686        });
687    }
688
689    let rows = combos.len();
690    let cols = high.len();
691    let warmups: Vec<usize> = combos
692        .iter()
693        .map(|combo| first + combo.length.unwrap() - 1)
694        .collect();
695
696    let mut buf = make_uninit_matrix(rows, cols);
697    init_matrix_prefixes(&mut buf, cols, &warmups);
698    let mut guard = ManuallyDrop::new(buf);
699    let out: &mut [f64] =
700        unsafe { core::slice::from_raw_parts_mut(guard.as_mut_ptr() as *mut f64, guard.len()) };
701
702    trend_trigger_factor_batch_inner_into(high, low, sweep, kernel, parallel, out)?;
703
704    let values = unsafe {
705        Vec::from_raw_parts(
706            guard.as_mut_ptr() as *mut f64,
707            guard.len(),
708            guard.capacity(),
709        )
710    };
711
712    Ok(TrendTriggerFactorBatchOutput {
713        values,
714        combos,
715        rows,
716        cols,
717    })
718}
719
720pub fn trend_trigger_factor_batch_into_slice(
721    out: &mut [f64],
722    high: &[f64],
723    low: &[f64],
724    sweep: &TrendTriggerFactorBatchRange,
725    kernel: Kernel,
726) -> Result<(), TrendTriggerFactorError> {
727    trend_trigger_factor_batch_inner_into(high, low, sweep, kernel, false, out)?;
728    Ok(())
729}
730
731fn trend_trigger_factor_batch_inner_into(
732    high: &[f64],
733    low: &[f64],
734    sweep: &TrendTriggerFactorBatchRange,
735    _kernel: Kernel,
736    parallel: bool,
737    out: &mut [f64],
738) -> Result<Vec<TrendTriggerFactorParams>, TrendTriggerFactorError> {
739    let combos = expand_grid(sweep)?;
740    let first = validate_raw_slices(high, low)?;
741    let rows = combos.len();
742    let cols = high.len();
743    let expected = rows
744        .checked_mul(cols)
745        .ok_or_else(|| TrendTriggerFactorError::InvalidRange {
746            start: rows.to_string(),
747            end: cols.to_string(),
748            step: "rows*cols".to_string(),
749        })?;
750    if out.len() != expected {
751        return Err(TrendTriggerFactorError::OutputLengthMismatch {
752            expected,
753            got: out.len(),
754        });
755    }
756    let max_length = combos
757        .iter()
758        .map(|combo| combo.length.unwrap())
759        .max()
760        .unwrap();
761    let valid = cols.saturating_sub(first);
762    if valid < max_length {
763        return Err(TrendTriggerFactorError::NotEnoughValidData {
764            needed: max_length,
765            valid,
766        });
767    }
768
769    let do_row = |row: usize, dst: &mut [f64]| {
770        dst.fill(f64::NAN);
771        compute_trend_trigger_factor_into(high, low, combos[row].length.unwrap(), first, dst);
772    };
773
774    if parallel {
775        #[cfg(not(target_arch = "wasm32"))]
776        {
777            out.par_chunks_mut(cols)
778                .enumerate()
779                .for_each(|(row, dst)| do_row(row, dst));
780        }
781        #[cfg(target_arch = "wasm32")]
782        {
783            for (row, dst) in out.chunks_mut(cols).enumerate() {
784                do_row(row, dst);
785            }
786        }
787    } else {
788        for (row, dst) in out.chunks_mut(cols).enumerate() {
789            do_row(row, dst);
790        }
791    }
792
793    Ok(combos)
794}
795
796#[cfg(feature = "python")]
797#[pyfunction(name = "trend_trigger_factor")]
798#[pyo3(signature = (high, low, length=15, kernel=None))]
799pub fn trend_trigger_factor_py<'py>(
800    py: Python<'py>,
801    high: PyReadonlyArray1<'py, f64>,
802    low: PyReadonlyArray1<'py, f64>,
803    length: usize,
804    kernel: Option<&str>,
805) -> PyResult<Bound<'py, PyArray1<f64>>> {
806    let high = high.as_slice()?;
807    let low = low.as_slice()?;
808    let input = TrendTriggerFactorInput::from_slices(
809        high,
810        low,
811        TrendTriggerFactorParams {
812            length: Some(length),
813        },
814    );
815    let kernel = validate_kernel(kernel, false)?;
816    let out = py
817        .allow_threads(|| trend_trigger_factor_with_kernel(&input, kernel))
818        .map_err(|e| PyValueError::new_err(e.to_string()))?;
819    Ok(out.values.into_pyarray(py))
820}
821
822#[cfg(feature = "python")]
823#[pyclass(name = "TrendTriggerFactorStream")]
824pub struct TrendTriggerFactorStreamPy {
825    stream: TrendTriggerFactorStream,
826}
827
828#[cfg(feature = "python")]
829#[pymethods]
830impl TrendTriggerFactorStreamPy {
831    #[new]
832    #[pyo3(signature = (length=15))]
833    fn new(length: usize) -> PyResult<Self> {
834        let stream = TrendTriggerFactorStream::try_new(TrendTriggerFactorParams {
835            length: Some(length),
836        })
837        .map_err(|e| PyValueError::new_err(e.to_string()))?;
838        Ok(Self { stream })
839    }
840
841    fn update(&mut self, high: f64, low: f64) -> f64 {
842        self.stream.update(high, low)
843    }
844}
845
846#[cfg(feature = "python")]
847#[pyfunction(name = "trend_trigger_factor_batch")]
848#[pyo3(signature = (high, low, length_range=(15,15,0), kernel=None))]
849pub fn trend_trigger_factor_batch_py<'py>(
850    py: Python<'py>,
851    high: PyReadonlyArray1<'py, f64>,
852    low: PyReadonlyArray1<'py, f64>,
853    length_range: (usize, usize, usize),
854    kernel: Option<&str>,
855) -> PyResult<Bound<'py, PyDict>> {
856    let high = high.as_slice()?;
857    let low = low.as_slice()?;
858    let sweep = TrendTriggerFactorBatchRange {
859        length: length_range,
860    };
861    let combos = expand_grid(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
862    let rows = combos.len();
863    let cols = high.len();
864    let total = rows
865        .checked_mul(cols)
866        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
867
868    let out = unsafe { PyArray1::<f64>::new(py, [total], false) };
869    let out_slice = unsafe { out.as_slice_mut()? };
870    let kernel = validate_kernel(kernel, true)?;
871
872    py.allow_threads(|| {
873        let batch_kernel = match kernel {
874            Kernel::Auto => detect_best_batch_kernel(),
875            other => other,
876        };
877        trend_trigger_factor_batch_inner_into(
878            high,
879            low,
880            &sweep,
881            batch_kernel.to_non_batch(),
882            true,
883            out_slice,
884        )
885    })
886    .map_err(|e| PyValueError::new_err(e.to_string()))?;
887
888    let dict = PyDict::new(py);
889    dict.set_item("values", out.reshape((rows, cols))?)?;
890    dict.set_item(
891        "lengths",
892        combos
893            .iter()
894            .map(|combo| combo.length.unwrap_or(DEFAULT_LENGTH) as u64)
895            .collect::<Vec<_>>()
896            .into_pyarray(py),
897    )?;
898    dict.set_item("rows", rows)?;
899    dict.set_item("cols", cols)?;
900    Ok(dict)
901}
902
903#[cfg(feature = "python")]
904pub fn register_trend_trigger_factor_module(m: &Bound<'_, pyo3::types::PyModule>) -> PyResult<()> {
905    m.add_function(wrap_pyfunction!(trend_trigger_factor_py, m)?)?;
906    m.add_function(wrap_pyfunction!(trend_trigger_factor_batch_py, m)?)?;
907    m.add_class::<TrendTriggerFactorStreamPy>()?;
908    Ok(())
909}
910
911#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
912#[derive(Serialize, Deserialize)]
913pub struct TrendTriggerFactorBatchConfig {
914    pub length_range: Vec<f64>,
915}
916
917#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
918#[derive(Serialize, Deserialize)]
919pub struct TrendTriggerFactorBatchJsOutput {
920    pub values: Vec<f64>,
921    pub lengths: Vec<usize>,
922    pub rows: usize,
923    pub cols: usize,
924}
925
926#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
927fn js_vec3_to_usize(name: &str, values: &[f64]) -> Result<(usize, usize, usize), JsValue> {
928    if values.len() != 3 {
929        return Err(JsValue::from_str(&format!(
930            "Invalid config: {name} must have exactly 3 elements [start, end, step]"
931        )));
932    }
933    let mut out = [0usize; 3];
934    for (i, value) in values.iter().copied().enumerate() {
935        if !value.is_finite() || value < 0.0 {
936            return Err(JsValue::from_str(&format!(
937                "Invalid config: {name}[{i}] must be a finite non-negative whole number"
938            )));
939        }
940        let rounded = value.round();
941        if (value - rounded).abs() > 1e-9 {
942            return Err(JsValue::from_str(&format!(
943                "Invalid config: {name}[{i}] must be a whole number"
944            )));
945        }
946        out[i] = rounded as usize;
947    }
948    Ok((out[0], out[1], out[2]))
949}
950
951#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
952#[wasm_bindgen(js_name = "trend_trigger_factor_js")]
953pub fn trend_trigger_factor_js(
954    high: &[f64],
955    low: &[f64],
956    length: usize,
957) -> Result<Vec<f64>, JsValue> {
958    let input = TrendTriggerFactorInput::from_slices(
959        high,
960        low,
961        TrendTriggerFactorParams {
962            length: Some(length),
963        },
964    );
965    trend_trigger_factor_with_kernel(&input, Kernel::Auto)
966        .map(|out| out.values)
967        .map_err(|e| JsValue::from_str(&e.to_string()))
968}
969
970#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
971#[wasm_bindgen(js_name = "trend_trigger_factor_batch_js")]
972pub fn trend_trigger_factor_batch_js(
973    high: &[f64],
974    low: &[f64],
975    config: JsValue,
976) -> Result<JsValue, JsValue> {
977    let config: TrendTriggerFactorBatchConfig = serde_wasm_bindgen::from_value(config)
978        .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
979    let sweep = TrendTriggerFactorBatchRange {
980        length: js_vec3_to_usize("length_range", &config.length_range)?,
981    };
982    let out = trend_trigger_factor_batch_with_kernel(high, low, &sweep, Kernel::Auto)
983        .map_err(|e| JsValue::from_str(&e.to_string()))?;
984    let lengths = out
985        .combos
986        .iter()
987        .map(|combo| combo.length.unwrap_or(DEFAULT_LENGTH))
988        .collect();
989    serde_wasm_bindgen::to_value(&TrendTriggerFactorBatchJsOutput {
990        values: out.values,
991        lengths,
992        rows: out.rows,
993        cols: out.cols,
994    })
995    .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
996}
997
998#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
999#[wasm_bindgen]
1000pub fn trend_trigger_factor_alloc(len: usize) -> *mut f64 {
1001    let mut vec = Vec::<f64>::with_capacity(len);
1002    let ptr = vec.as_mut_ptr();
1003    std::mem::forget(vec);
1004    ptr
1005}
1006
1007#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1008#[wasm_bindgen]
1009pub fn trend_trigger_factor_free(ptr: *mut f64, len: usize) {
1010    if !ptr.is_null() {
1011        unsafe {
1012            let _ = Vec::from_raw_parts(ptr, len, len);
1013        }
1014    }
1015}
1016
1017#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1018#[wasm_bindgen]
1019pub fn trend_trigger_factor_into(
1020    high_ptr: *const f64,
1021    low_ptr: *const f64,
1022    out_ptr: *mut f64,
1023    len: usize,
1024    length: usize,
1025) -> Result<(), JsValue> {
1026    if high_ptr.is_null() || low_ptr.is_null() || out_ptr.is_null() {
1027        return Err(JsValue::from_str("Null pointer provided"));
1028    }
1029    unsafe {
1030        let high = std::slice::from_raw_parts(high_ptr, len);
1031        let low = std::slice::from_raw_parts(low_ptr, len);
1032        let out = std::slice::from_raw_parts_mut(out_ptr, len);
1033        let input = TrendTriggerFactorInput::from_slices(
1034            high,
1035            low,
1036            TrendTriggerFactorParams {
1037                length: Some(length),
1038            },
1039        );
1040        trend_trigger_factor_into_slice(out, &input, Kernel::Auto)
1041            .map_err(|e| JsValue::from_str(&e.to_string()))
1042    }
1043}
1044
1045#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1046#[wasm_bindgen]
1047pub fn trend_trigger_factor_batch_into(
1048    high_ptr: *const f64,
1049    low_ptr: *const f64,
1050    out_ptr: *mut f64,
1051    len: usize,
1052    start: usize,
1053    end: usize,
1054    step: usize,
1055) -> Result<usize, JsValue> {
1056    if high_ptr.is_null() || low_ptr.is_null() || out_ptr.is_null() {
1057        return Err(JsValue::from_str("Null pointer provided"));
1058    }
1059    unsafe {
1060        let high = std::slice::from_raw_parts(high_ptr, len);
1061        let low = std::slice::from_raw_parts(low_ptr, len);
1062        let sweep = TrendTriggerFactorBatchRange {
1063            length: (start, end, step),
1064        };
1065        let combos = expand_grid(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
1066        let rows = combos.len();
1067        let out = std::slice::from_raw_parts_mut(out_ptr, rows * len);
1068        trend_trigger_factor_batch_into_slice(out, high, low, &sweep, Kernel::Scalar)
1069            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1070        Ok(rows)
1071    }
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077    use crate::utilities::enums::Kernel;
1078
1079    fn sample_high_low(len: usize) -> (Vec<f64>, Vec<f64>) {
1080        let mut high = Vec::with_capacity(len);
1081        let mut low = Vec::with_capacity(len);
1082        for i in 0..len {
1083            let base = 100.0 + (i as f64 * 0.17).sin() * 2.0 + (i as f64) * 0.05;
1084            high.push(base + 1.5 + (i as f64 * 0.11).cos() * 0.2);
1085            low.push(base - 1.5 - (i as f64 * 0.07).sin() * 0.2);
1086        }
1087        (high, low)
1088    }
1089
1090    fn manual_ttf(high: &[f64], low: &[f64], length: usize) -> Vec<f64> {
1091        let n = high.len();
1092        let mut out = vec![f64::NAN; n];
1093        let first = first_valid_high_low(high, low).unwrap();
1094        let warm = first + length - 1;
1095        let mut hh_series = vec![f64::NAN; n];
1096        let mut ll_series = vec![f64::NAN; n];
1097
1098        for i in warm..n {
1099            let start = i + 1 - length;
1100            let mut hh = f64::NEG_INFINITY;
1101            let mut ll = f64::INFINITY;
1102            for j in start..=i {
1103                hh = hh.max(high[j]);
1104                ll = ll.min(low[j]);
1105            }
1106            hh_series[i] = hh;
1107            ll_series[i] = ll;
1108            let hist_hh = if i >= length && hh_series[i - length].is_finite() {
1109                hh_series[i - length]
1110            } else {
1111                0.0
1112            };
1113            let hist_ll = if i >= length && ll_series[i - length].is_finite() {
1114                ll_series[i - length]
1115            } else {
1116                0.0
1117            };
1118            out[i] = calc_ttf(hh, ll, hist_hh, hist_ll);
1119        }
1120
1121        out
1122    }
1123
1124    fn assert_vec_close(got: &[f64], want: &[f64]) {
1125        assert_eq!(got.len(), want.len());
1126        for (idx, (g, w)) in got.iter().zip(want.iter()).enumerate() {
1127            if g.is_nan() || w.is_nan() {
1128                assert!(g.is_nan() && w.is_nan(), "index={idx} got={g} want={w}");
1129            } else {
1130                assert!((g - w).abs() <= 1e-12, "index={idx} got={g} want={w}");
1131            }
1132        }
1133    }
1134
1135    #[test]
1136    fn manual_reference_matches_api() {
1137        let (high, low) = sample_high_low(96);
1138        let expected = manual_ttf(&high, &low, 15);
1139        let input = TrendTriggerFactorInput::from_slices(
1140            &high,
1141            &low,
1142            TrendTriggerFactorParams { length: Some(15) },
1143        );
1144        let out = trend_trigger_factor(&input).unwrap();
1145        for (got, want) in out.values.iter().zip(expected.iter()) {
1146            if got.is_nan() || want.is_nan() {
1147                assert!(got.is_nan() && want.is_nan());
1148            } else {
1149                assert!((got - want).abs() <= 1e-12, "got={got} want={want}");
1150            }
1151        }
1152    }
1153
1154    #[test]
1155    fn stream_matches_batch() {
1156        let (high, low) = sample_high_low(128);
1157        let input = TrendTriggerFactorInput::from_slices(
1158            &high,
1159            &low,
1160            TrendTriggerFactorParams { length: Some(15) },
1161        );
1162        let batch = trend_trigger_factor(&input).unwrap();
1163        let mut stream =
1164            TrendTriggerFactorStream::try_new(TrendTriggerFactorParams { length: Some(15) })
1165                .unwrap();
1166        for i in 0..high.len() {
1167            let got = stream.update(high[i], low[i]);
1168            let want = batch.values[i];
1169            if got.is_nan() || want.is_nan() {
1170                assert!(got.is_nan() && want.is_nan());
1171            } else {
1172                assert!(
1173                    (got - want).abs() <= 1e-12,
1174                    "index={i} got={got} want={want}"
1175                );
1176            }
1177        }
1178    }
1179
1180    #[test]
1181    fn batch_first_row_matches_single() {
1182        let (high, low) = sample_high_low(144);
1183        let batch = trend_trigger_factor_batch_with_kernel(
1184            &high,
1185            &low,
1186            &TrendTriggerFactorBatchRange {
1187                length: (15, 17, 2),
1188            },
1189            Kernel::Auto,
1190        )
1191        .unwrap();
1192        let single = trend_trigger_factor(&TrendTriggerFactorInput::from_slices(
1193            &high,
1194            &low,
1195            TrendTriggerFactorParams { length: Some(15) },
1196        ))
1197        .unwrap();
1198        assert_eq!(batch.rows, 2);
1199        assert_eq!(batch.cols, high.len());
1200        assert_vec_close(&batch.values[..high.len()], single.values.as_slice());
1201    }
1202
1203    #[test]
1204    fn into_slice_matches_single() {
1205        let (high, low) = sample_high_low(120);
1206        let input = TrendTriggerFactorInput::from_slices(
1207            &high,
1208            &low,
1209            TrendTriggerFactorParams { length: Some(15) },
1210        );
1211        let single = trend_trigger_factor(&input).unwrap();
1212        let mut out = vec![0.0; high.len()];
1213        trend_trigger_factor_into_slice(&mut out, &input, Kernel::Auto).unwrap();
1214        assert_vec_close(&out, &single.values);
1215    }
1216
1217    #[test]
1218    fn invalid_length_is_rejected() {
1219        let (high, low) = sample_high_low(32);
1220        let input = TrendTriggerFactorInput::from_slices(
1221            &high,
1222            &low,
1223            TrendTriggerFactorParams { length: Some(0) },
1224        );
1225        let err = trend_trigger_factor(&input).unwrap_err();
1226        assert!(err.to_string().contains("Invalid length"));
1227    }
1228}