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}