Skip to main content

vector_ta/indicators/
hypertrend.rs

1#[cfg(feature = "python")]
2use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
3#[cfg(feature = "python")]
4use pyo3::exceptions::PyValueError;
5#[cfg(feature = "python")]
6use pyo3::prelude::*;
7#[cfg(feature = "python")]
8use pyo3::types::PyDict;
9
10#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
11use serde::{Deserialize, Serialize};
12#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
13use wasm_bindgen::prelude::*;
14
15use crate::utilities::data_loader::{source_type, Candles};
16use crate::utilities::enums::Kernel;
17use crate::utilities::helpers::{
18    alloc_with_nan_prefix, detect_best_batch_kernel, detect_best_kernel, init_matrix_prefixes,
19    make_uninit_matrix,
20};
21#[cfg(feature = "python")]
22use crate::utilities::kernel_validation::validate_kernel;
23
24#[cfg(not(target_arch = "wasm32"))]
25use rayon::prelude::*;
26use std::mem::{ManuallyDrop, MaybeUninit};
27use thiserror::Error;
28
29const DEFAULT_FACTOR: f64 = 5.0;
30const DEFAULT_SLOPE: f64 = 14.0;
31const DEFAULT_WIDTH_PERCENT: f64 = 80.0;
32const ATR_PERIOD: usize = 200;
33
34#[derive(Debug, Clone)]
35pub enum HyperTrendData<'a> {
36    Candles {
37        candles: &'a Candles,
38        source: &'a str,
39    },
40    Slices {
41        high: &'a [f64],
42        low: &'a [f64],
43        source: &'a [f64],
44    },
45}
46
47#[derive(Debug, Clone)]
48pub struct HyperTrendOutput {
49    pub upper: Vec<f64>,
50    pub average: Vec<f64>,
51    pub lower: Vec<f64>,
52    pub trend: Vec<f64>,
53    pub changed: Vec<f64>,
54}
55
56#[derive(Debug, Clone)]
57#[cfg_attr(
58    all(target_arch = "wasm32", feature = "wasm"),
59    derive(Serialize, Deserialize)
60)]
61pub struct HyperTrendParams {
62    pub factor: Option<f64>,
63    pub slope: Option<f64>,
64    pub width_percent: Option<f64>,
65}
66
67impl Default for HyperTrendParams {
68    fn default() -> Self {
69        Self {
70            factor: Some(DEFAULT_FACTOR),
71            slope: Some(DEFAULT_SLOPE),
72            width_percent: Some(DEFAULT_WIDTH_PERCENT),
73        }
74    }
75}
76
77#[derive(Debug, Clone)]
78pub struct HyperTrendInput<'a> {
79    pub data: HyperTrendData<'a>,
80    pub params: HyperTrendParams,
81}
82
83impl<'a> HyperTrendInput<'a> {
84    #[inline]
85    pub fn from_candles(candles: &'a Candles, source: &'a str, params: HyperTrendParams) -> Self {
86        Self {
87            data: HyperTrendData::Candles { candles, source },
88            params,
89        }
90    }
91
92    #[inline]
93    pub fn from_slices(
94        high: &'a [f64],
95        low: &'a [f64],
96        source: &'a [f64],
97        params: HyperTrendParams,
98    ) -> Self {
99        Self {
100            data: HyperTrendData::Slices { high, low, source },
101            params,
102        }
103    }
104
105    #[inline]
106    pub fn with_default_candles(candles: &'a Candles) -> Self {
107        Self::from_candles(candles, "close", HyperTrendParams::default())
108    }
109
110    #[inline]
111    pub fn get_factor(&self) -> f64 {
112        self.params.factor.unwrap_or(DEFAULT_FACTOR)
113    }
114
115    #[inline]
116    pub fn get_slope(&self) -> f64 {
117        self.params.slope.unwrap_or(DEFAULT_SLOPE)
118    }
119
120    #[inline]
121    pub fn get_width_percent(&self) -> f64 {
122        self.params.width_percent.unwrap_or(DEFAULT_WIDTH_PERCENT)
123    }
124
125    #[inline]
126    pub fn as_refs(&'a self) -> (&'a [f64], &'a [f64], &'a [f64]) {
127        match &self.data {
128            HyperTrendData::Candles { candles, source } => (
129                candles.high.as_slice(),
130                candles.low.as_slice(),
131                source_type(candles, source),
132            ),
133            HyperTrendData::Slices { high, low, source } => (*high, *low, *source),
134        }
135    }
136}
137
138#[derive(Clone, Debug)]
139pub struct HyperTrendBuilder {
140    factor: Option<f64>,
141    slope: Option<f64>,
142    width_percent: Option<f64>,
143    source: Option<String>,
144    kernel: Kernel,
145}
146
147impl Default for HyperTrendBuilder {
148    fn default() -> Self {
149        Self {
150            factor: None,
151            slope: None,
152            width_percent: None,
153            source: None,
154            kernel: Kernel::Auto,
155        }
156    }
157}
158
159impl HyperTrendBuilder {
160    #[inline]
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    #[inline]
166    pub fn factor(mut self, value: f64) -> Self {
167        self.factor = Some(value);
168        self
169    }
170
171    #[inline]
172    pub fn slope(mut self, value: f64) -> Self {
173        self.slope = Some(value);
174        self
175    }
176
177    #[inline]
178    pub fn width_percent(mut self, value: f64) -> Self {
179        self.width_percent = Some(value);
180        self
181    }
182
183    #[inline]
184    pub fn source<S: Into<String>>(mut self, value: S) -> Self {
185        self.source = Some(value.into());
186        self
187    }
188
189    #[inline]
190    pub fn kernel(mut self, value: Kernel) -> Self {
191        self.kernel = value;
192        self
193    }
194
195    #[inline]
196    pub fn apply(self, candles: &Candles) -> Result<HyperTrendOutput, HyperTrendError> {
197        let input = HyperTrendInput::from_candles(
198            candles,
199            self.source.as_deref().unwrap_or("close"),
200            HyperTrendParams {
201                factor: self.factor,
202                slope: self.slope,
203                width_percent: self.width_percent,
204            },
205        );
206        hypertrend_with_kernel(&input, self.kernel)
207    }
208
209    #[inline]
210    pub fn apply_slices(
211        self,
212        high: &[f64],
213        low: &[f64],
214        source: &[f64],
215    ) -> Result<HyperTrendOutput, HyperTrendError> {
216        let input = HyperTrendInput::from_slices(
217            high,
218            low,
219            source,
220            HyperTrendParams {
221                factor: self.factor,
222                slope: self.slope,
223                width_percent: self.width_percent,
224            },
225        );
226        hypertrend_with_kernel(&input, self.kernel)
227    }
228
229    #[inline]
230    pub fn into_stream(self) -> Result<HyperTrendStream, HyperTrendError> {
231        HyperTrendStream::try_new(HyperTrendParams {
232            factor: self.factor,
233            slope: self.slope,
234            width_percent: self.width_percent,
235        })
236    }
237}
238
239#[derive(Debug, Error)]
240pub enum HyperTrendError {
241    #[error("hypertrend: Empty input data.")]
242    EmptyInputData,
243    #[error("hypertrend: Input length mismatch: high={high}, low={low}, source={source_len}")]
244    DataLengthMismatch {
245        high: usize,
246        low: usize,
247        source_len: usize,
248    },
249    #[error("hypertrend: All input values are invalid.")]
250    AllValuesNaN,
251    #[error("hypertrend: Invalid factor: {factor}")]
252    InvalidFactor { factor: f64 },
253    #[error("hypertrend: Invalid slope: {slope}")]
254    InvalidSlope { slope: f64 },
255    #[error("hypertrend: Invalid width_percent: {width_percent}")]
256    InvalidWidthPercent { width_percent: f64 },
257    #[error("hypertrend: Output length mismatch: expected={expected}, got={got}")]
258    OutputLengthMismatch { expected: usize, got: usize },
259    #[error("hypertrend: Invalid range: start={start}, end={end}, step={step}")]
260    InvalidRange {
261        start: String,
262        end: String,
263        step: String,
264    },
265    #[error("hypertrend: Invalid float range: start={start}, end={end}, step={step}")]
266    InvalidFloatRange { start: f64, end: f64, step: f64 },
267    #[error("hypertrend: Invalid kernel for batch: {0:?}")]
268    InvalidKernelForBatch(Kernel),
269}
270
271#[inline(always)]
272fn valid_bar(high: f64, low: f64, source: f64) -> bool {
273    high.is_finite() && low.is_finite() && source.is_finite() && high >= low
274}
275
276#[inline(always)]
277fn first_valid_bar(high: &[f64], low: &[f64], source: &[f64]) -> Option<usize> {
278    (0..source.len()).find(|&i| valid_bar(high[i], low[i], source[i]))
279}
280
281#[inline(always)]
282fn normalize_kernel(kernel: Kernel) -> Kernel {
283    match kernel {
284        Kernel::Auto => detect_best_kernel(),
285        other if other.is_batch() => other.to_non_batch(),
286        other => other,
287    }
288}
289
290#[inline(always)]
291fn validate_lengths(high: &[f64], low: &[f64], source: &[f64]) -> Result<(), HyperTrendError> {
292    if high.is_empty() || low.is_empty() || source.is_empty() {
293        return Err(HyperTrendError::EmptyInputData);
294    }
295    if high.len() != low.len() || low.len() != source.len() {
296        return Err(HyperTrendError::DataLengthMismatch {
297            high: high.len(),
298            low: low.len(),
299            source_len: source.len(),
300        });
301    }
302    Ok(())
303}
304
305#[inline(always)]
306fn validate_params(factor: f64, slope: f64, width_percent: f64) -> Result<(), HyperTrendError> {
307    if !factor.is_finite() || factor <= 0.0 {
308        return Err(HyperTrendError::InvalidFactor { factor });
309    }
310    if !slope.is_finite() || slope <= 0.0 {
311        return Err(HyperTrendError::InvalidSlope { slope });
312    }
313    if !width_percent.is_finite() || !(0.0..=100.0).contains(&width_percent) {
314        return Err(HyperTrendError::InvalidWidthPercent { width_percent });
315    }
316    Ok(())
317}
318
319#[inline(always)]
320fn pine_sign(value: f64) -> f64 {
321    if value > 0.0 {
322        1.0
323    } else if value < 0.0 {
324        -1.0
325    } else {
326        0.0
327    }
328}
329
330#[inline(always)]
331fn true_range(high: f64, low: f64, prev_close: f64) -> f64 {
332    if prev_close.is_finite() {
333        let a = high - low;
334        let b = (high - prev_close).abs();
335        let c = (low - prev_close).abs();
336        a.max(b).max(c)
337    } else {
338        high - low
339    }
340}
341
342fn compute_atr_zeroed(high: &[f64], low: &[f64], source: &[f64]) -> Vec<f64> {
343    let mut out = vec![0.0; source.len()];
344    let mut prev_close = f64::NAN;
345    let mut seed_sum = 0.0;
346    let mut seed_count = 0usize;
347    let mut atr = f64::NAN;
348
349    for i in 0..source.len() {
350        if !valid_bar(high[i], low[i], source[i]) {
351            out[i] = 0.0;
352            prev_close = f64::NAN;
353            seed_sum = 0.0;
354            seed_count = 0;
355            atr = f64::NAN;
356            continue;
357        }
358
359        let tr = true_range(high[i], low[i], prev_close);
360        prev_close = source[i];
361
362        if seed_count < ATR_PERIOD {
363            seed_sum += tr;
364            seed_count += 1;
365            if seed_count == ATR_PERIOD {
366                atr = seed_sum / ATR_PERIOD as f64;
367                out[i] = atr;
368            }
369            continue;
370        }
371
372        atr = ((atr * (ATR_PERIOD as f64 - 1.0)) + tr) / ATR_PERIOD as f64;
373        out[i] = atr;
374    }
375
376    out
377}
378
379#[inline(always)]
380fn hypertrend_row_scalar(
381    high: &[f64],
382    low: &[f64],
383    source: &[f64],
384    factor: f64,
385    slope: f64,
386    width_ratio: f64,
387    atr_values: &[f64],
388    out_upper: &mut [f64],
389    out_average: &mut [f64],
390    out_lower: &mut [f64],
391    out_trend: &mut [f64],
392    out_changed: &mut [f64],
393) {
394    let mut initialized = false;
395    let mut avg = 0.0;
396    let mut hold = 0.0;
397    let mut os = 1.0;
398
399    for i in 0..source.len() {
400        let src = source[i];
401        if !valid_bar(high[i], low[i], src) {
402            out_upper[i] = f64::NAN;
403            out_average[i] = f64::NAN;
404            out_lower[i] = f64::NAN;
405            out_trend[i] = f64::NAN;
406            out_changed[i] = f64::NAN;
407            initialized = false;
408            avg = 0.0;
409            hold = 0.0;
410            os = 1.0;
411            continue;
412        }
413
414        if !initialized {
415            avg = src;
416            hold = 0.0;
417            os = 1.0;
418            out_average[i] = avg;
419            out_upper[i] = avg;
420            out_lower[i] = avg;
421            out_trend[i] = os;
422            out_changed[i] = 0.0;
423            initialized = true;
424            continue;
425        }
426
427        let atr = atr_values[i] * factor;
428        let next_avg = if (src - avg).abs() > atr {
429            0.5 * (src + avg)
430        } else {
431            avg + os * (hold / factor / slope)
432        };
433        let next_os = pine_sign(next_avg - avg);
434        let changed = if next_os != os { 1.0 } else { 0.0 };
435        let next_hold = if changed != 0.0 { atr } else { hold };
436        let upper = next_avg + width_ratio * next_hold;
437        let lower = next_avg - width_ratio * next_hold;
438
439        out_upper[i] = upper;
440        out_average[i] = next_avg;
441        out_lower[i] = lower;
442        out_trend[i] = next_os;
443        out_changed[i] = changed;
444
445        avg = next_avg;
446        hold = next_hold;
447        os = next_os;
448    }
449}
450
451#[inline]
452pub fn hypertrend(input: &HyperTrendInput) -> Result<HyperTrendOutput, HyperTrendError> {
453    hypertrend_with_kernel(input, Kernel::Auto)
454}
455
456#[inline]
457pub fn hypertrend_with_kernel(
458    input: &HyperTrendInput,
459    kernel: Kernel,
460) -> Result<HyperTrendOutput, HyperTrendError> {
461    let (high, low, source) = input.as_refs();
462    validate_lengths(high, low, source)?;
463
464    let factor = input.get_factor();
465    let slope = input.get_slope();
466    let width_percent = input.get_width_percent();
467    validate_params(factor, slope, width_percent)?;
468
469    let first_valid = first_valid_bar(high, low, source).ok_or(HyperTrendError::AllValuesNaN)?;
470    let _kernel = normalize_kernel(kernel);
471    let atr_values = compute_atr_zeroed(high, low, source);
472    let width_ratio = width_percent * 0.01;
473    let len = source.len();
474
475    let mut upper = alloc_with_nan_prefix(len, first_valid);
476    let mut average = alloc_with_nan_prefix(len, first_valid);
477    let mut lower = alloc_with_nan_prefix(len, first_valid);
478    let mut trend = alloc_with_nan_prefix(len, first_valid);
479    let mut changed = alloc_with_nan_prefix(len, first_valid);
480
481    hypertrend_row_scalar(
482        high,
483        low,
484        source,
485        factor,
486        slope,
487        width_ratio,
488        &atr_values,
489        &mut upper,
490        &mut average,
491        &mut lower,
492        &mut trend,
493        &mut changed,
494    );
495
496    Ok(HyperTrendOutput {
497        upper,
498        average,
499        lower,
500        trend,
501        changed,
502    })
503}
504
505#[inline]
506pub fn hypertrend_into_slice(
507    out_upper: &mut [f64],
508    out_average: &mut [f64],
509    out_lower: &mut [f64],
510    out_trend: &mut [f64],
511    out_changed: &mut [f64],
512    input: &HyperTrendInput,
513    kernel: Kernel,
514) -> Result<(), HyperTrendError> {
515    let (high, low, source) = input.as_refs();
516    validate_lengths(high, low, source)?;
517    let len = source.len();
518    if out_upper.len() != len
519        || out_average.len() != len
520        || out_lower.len() != len
521        || out_trend.len() != len
522        || out_changed.len() != len
523    {
524        return Err(HyperTrendError::OutputLengthMismatch {
525            expected: len,
526            got: out_upper
527                .len()
528                .max(out_average.len())
529                .max(out_lower.len())
530                .max(out_trend.len())
531                .max(out_changed.len()),
532        });
533    }
534
535    let factor = input.get_factor();
536    let slope = input.get_slope();
537    let width_percent = input.get_width_percent();
538    validate_params(factor, slope, width_percent)?;
539    let _kernel = normalize_kernel(kernel);
540    let atr_values = compute_atr_zeroed(high, low, source);
541
542    hypertrend_row_scalar(
543        high,
544        low,
545        source,
546        factor,
547        slope,
548        width_percent * 0.01,
549        &atr_values,
550        out_upper,
551        out_average,
552        out_lower,
553        out_trend,
554        out_changed,
555    );
556    Ok(())
557}
558
559#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
560#[inline]
561pub fn hypertrend_into(
562    input: &HyperTrendInput,
563    out_upper: &mut [f64],
564    out_average: &mut [f64],
565    out_lower: &mut [f64],
566    out_trend: &mut [f64],
567    out_changed: &mut [f64],
568) -> Result<(), HyperTrendError> {
569    hypertrend_into_slice(
570        out_upper,
571        out_average,
572        out_lower,
573        out_trend,
574        out_changed,
575        input,
576        Kernel::Auto,
577    )
578}
579
580#[derive(Clone, Debug)]
581struct HyperTrendAtrState {
582    prev_close: f64,
583    seed_sum: f64,
584    seed_count: usize,
585    atr: f64,
586}
587
588impl HyperTrendAtrState {
589    #[inline]
590    fn new() -> Self {
591        Self {
592            prev_close: f64::NAN,
593            seed_sum: 0.0,
594            seed_count: 0,
595            atr: f64::NAN,
596        }
597    }
598
599    #[inline]
600    fn reset(&mut self) {
601        self.prev_close = f64::NAN;
602        self.seed_sum = 0.0;
603        self.seed_count = 0;
604        self.atr = f64::NAN;
605    }
606
607    #[inline]
608    fn update(&mut self, high: f64, low: f64, source: f64) -> Option<f64> {
609        if !valid_bar(high, low, source) {
610            self.reset();
611            return None;
612        }
613
614        let tr = true_range(high, low, self.prev_close);
615        self.prev_close = source;
616
617        if self.seed_count < ATR_PERIOD {
618            self.seed_sum += tr;
619            self.seed_count += 1;
620            if self.seed_count == ATR_PERIOD {
621                self.atr = self.seed_sum / ATR_PERIOD as f64;
622                return Some(self.atr);
623            }
624            return Some(0.0);
625        }
626
627        self.atr = ((self.atr * (ATR_PERIOD as f64 - 1.0)) + tr) / ATR_PERIOD as f64;
628        Some(self.atr)
629    }
630}
631
632#[derive(Clone, Debug)]
633pub struct HyperTrendStream {
634    factor: f64,
635    slope: f64,
636    width_ratio: f64,
637    atr: HyperTrendAtrState,
638    initialized: bool,
639    avg: f64,
640    hold: f64,
641    os: f64,
642}
643
644impl HyperTrendStream {
645    #[inline]
646    pub fn try_new(params: HyperTrendParams) -> Result<Self, HyperTrendError> {
647        let factor = params.factor.unwrap_or(DEFAULT_FACTOR);
648        let slope = params.slope.unwrap_or(DEFAULT_SLOPE);
649        let width_percent = params.width_percent.unwrap_or(DEFAULT_WIDTH_PERCENT);
650        validate_params(factor, slope, width_percent)?;
651        Ok(Self {
652            factor,
653            slope,
654            width_ratio: width_percent * 0.01,
655            atr: HyperTrendAtrState::new(),
656            initialized: false,
657            avg: 0.0,
658            hold: 0.0,
659            os: 1.0,
660        })
661    }
662
663    #[inline]
664    pub fn update(
665        &mut self,
666        high: f64,
667        low: f64,
668        source: f64,
669    ) -> Option<(f64, f64, f64, f64, f64)> {
670        let atr_raw = self.atr.update(high, low, source)?;
671        if !self.initialized {
672            self.avg = source;
673            self.hold = 0.0;
674            self.os = 1.0;
675            self.initialized = true;
676            return Some((source, source, source, 1.0, 0.0));
677        }
678
679        let atr = atr_raw * self.factor;
680        let next_avg = if (source - self.avg).abs() > atr {
681            0.5 * (source + self.avg)
682        } else {
683            self.avg + self.os * (self.hold / self.factor / self.slope)
684        };
685        let next_os = pine_sign(next_avg - self.avg);
686        let changed = if next_os != self.os { 1.0 } else { 0.0 };
687        let next_hold = if changed != 0.0 { atr } else { self.hold };
688        let upper = next_avg + self.width_ratio * next_hold;
689        let lower = next_avg - self.width_ratio * next_hold;
690
691        self.avg = next_avg;
692        self.hold = next_hold;
693        self.os = next_os;
694
695        Some((upper, next_avg, lower, next_os, changed))
696    }
697}
698
699#[derive(Clone, Debug)]
700pub struct HyperTrendBatchRange {
701    pub factor: (f64, f64, f64),
702    pub slope: (f64, f64, f64),
703    pub width_percent: (f64, f64, f64),
704}
705
706impl Default for HyperTrendBatchRange {
707    fn default() -> Self {
708        Self {
709            factor: (DEFAULT_FACTOR, DEFAULT_FACTOR, 0.0),
710            slope: (DEFAULT_SLOPE, DEFAULT_SLOPE, 0.0),
711            width_percent: (DEFAULT_WIDTH_PERCENT, DEFAULT_WIDTH_PERCENT, 0.0),
712        }
713    }
714}
715
716#[derive(Clone, Debug)]
717pub struct HyperTrendBatchOutput {
718    pub upper: Vec<f64>,
719    pub average: Vec<f64>,
720    pub lower: Vec<f64>,
721    pub trend: Vec<f64>,
722    pub changed: Vec<f64>,
723    pub combos: Vec<HyperTrendParams>,
724    pub rows: usize,
725    pub cols: usize,
726}
727
728#[derive(Clone, Debug)]
729pub struct HyperTrendBatchBuilder {
730    range: HyperTrendBatchRange,
731    kernel: Kernel,
732}
733
734impl Default for HyperTrendBatchBuilder {
735    fn default() -> Self {
736        Self {
737            range: HyperTrendBatchRange::default(),
738            kernel: Kernel::Auto,
739        }
740    }
741}
742
743impl HyperTrendBatchBuilder {
744    #[inline]
745    pub fn new() -> Self {
746        Self::default()
747    }
748
749    #[inline]
750    pub fn factor_range(mut self, range: (f64, f64, f64)) -> Self {
751        self.range.factor = range;
752        self
753    }
754
755    #[inline]
756    pub fn slope_range(mut self, range: (f64, f64, f64)) -> Self {
757        self.range.slope = range;
758        self
759    }
760
761    #[inline]
762    pub fn width_percent_range(mut self, range: (f64, f64, f64)) -> Self {
763        self.range.width_percent = range;
764        self
765    }
766
767    #[inline]
768    pub fn kernel(mut self, kernel: Kernel) -> Self {
769        self.kernel = kernel;
770        self
771    }
772
773    #[inline]
774    pub fn apply_slices(
775        self,
776        high: &[f64],
777        low: &[f64],
778        source: &[f64],
779    ) -> Result<HyperTrendBatchOutput, HyperTrendError> {
780        hypertrend_batch_with_kernel(high, low, source, &self.range, self.kernel)
781    }
782
783    #[inline]
784    pub fn apply(self, candles: &Candles) -> Result<HyperTrendBatchOutput, HyperTrendError> {
785        let source = source_type(candles, "close");
786        hypertrend_batch_with_kernel(
787            &candles.high,
788            &candles.low,
789            source,
790            &self.range,
791            self.kernel,
792        )
793    }
794}
795
796pub fn expand_grid_hypertrend(
797    range: &HyperTrendBatchRange,
798) -> Result<Vec<HyperTrendParams>, HyperTrendError> {
799    fn axis_f64((start, end, step): (f64, f64, f64)) -> Result<Vec<f64>, HyperTrendError> {
800        if !start.is_finite() || !end.is_finite() || !step.is_finite() {
801            return Err(HyperTrendError::InvalidFloatRange { start, end, step });
802        }
803        if step.abs() < 1e-12 || (start - end).abs() < 1e-12 {
804            return Ok(vec![start]);
805        }
806
807        let step = step.abs();
808        let mut out = Vec::new();
809        if start <= end {
810            let mut x = start;
811            while x <= end + 1e-12 {
812                out.push(x);
813                x += step;
814            }
815        } else {
816            let mut x = start;
817            while x + 1e-12 >= end {
818                out.push(x);
819                x -= step;
820            }
821        }
822
823        if out.is_empty() {
824            return Err(HyperTrendError::InvalidFloatRange { start, end, step });
825        }
826        Ok(out)
827    }
828
829    let factors = axis_f64(range.factor)?;
830    let slopes = axis_f64(range.slope)?;
831    let widths = axis_f64(range.width_percent)?;
832
833    let cap = factors
834        .len()
835        .checked_mul(slopes.len())
836        .and_then(|value| value.checked_mul(widths.len()))
837        .ok_or(HyperTrendError::InvalidRange {
838            start: range.factor.0.to_string(),
839            end: range.factor.1.to_string(),
840            step: range.factor.2.to_string(),
841        })?;
842
843    let mut out = Vec::with_capacity(cap);
844    for &factor in &factors {
845        for &slope in &slopes {
846            for &width_percent in &widths {
847                out.push(HyperTrendParams {
848                    factor: Some(factor),
849                    slope: Some(slope),
850                    width_percent: Some(width_percent),
851                });
852            }
853        }
854    }
855    Ok(out)
856}
857
858#[inline]
859pub fn hypertrend_batch_with_kernel(
860    high: &[f64],
861    low: &[f64],
862    source: &[f64],
863    sweep: &HyperTrendBatchRange,
864    kernel: Kernel,
865) -> Result<HyperTrendBatchOutput, HyperTrendError> {
866    let batch_kernel = match kernel {
867        Kernel::Auto => detect_best_batch_kernel(),
868        other if other.is_batch() => other,
869        other => return Err(HyperTrendError::InvalidKernelForBatch(other)),
870    };
871    hypertrend_batch_par_slice(high, low, source, sweep, batch_kernel.to_non_batch())
872}
873
874#[inline]
875pub fn hypertrend_batch_slice(
876    high: &[f64],
877    low: &[f64],
878    source: &[f64],
879    sweep: &HyperTrendBatchRange,
880    kernel: Kernel,
881) -> Result<HyperTrendBatchOutput, HyperTrendError> {
882    hypertrend_batch_inner(high, low, source, sweep, kernel, false)
883}
884
885#[inline]
886pub fn hypertrend_batch_par_slice(
887    high: &[f64],
888    low: &[f64],
889    source: &[f64],
890    sweep: &HyperTrendBatchRange,
891    kernel: Kernel,
892) -> Result<HyperTrendBatchOutput, HyperTrendError> {
893    hypertrend_batch_inner(high, low, source, sweep, kernel, true)
894}
895
896fn hypertrend_batch_inner(
897    high: &[f64],
898    low: &[f64],
899    source: &[f64],
900    sweep: &HyperTrendBatchRange,
901    _kernel: Kernel,
902    parallel: bool,
903) -> Result<HyperTrendBatchOutput, HyperTrendError> {
904    validate_lengths(high, low, source)?;
905    let combos = expand_grid_hypertrend(sweep)?;
906    for params in &combos {
907        validate_params(
908            params.factor.unwrap_or(DEFAULT_FACTOR),
909            params.slope.unwrap_or(DEFAULT_SLOPE),
910            params.width_percent.unwrap_or(DEFAULT_WIDTH_PERCENT),
911        )?;
912    }
913
914    let first_valid = first_valid_bar(high, low, source).ok_or(HyperTrendError::AllValuesNaN)?;
915    let rows = combos.len();
916    let cols = source.len();
917    let total = rows
918        .checked_mul(cols)
919        .ok_or(HyperTrendError::OutputLengthMismatch {
920            expected: usize::MAX,
921            got: 0,
922        })?;
923    let atr_values = compute_atr_zeroed(high, low, source);
924
925    let mut upper_matrix = make_uninit_matrix(rows, cols);
926    let mut average_matrix = make_uninit_matrix(rows, cols);
927    let mut lower_matrix = make_uninit_matrix(rows, cols);
928    let mut trend_matrix = make_uninit_matrix(rows, cols);
929    let mut changed_matrix = make_uninit_matrix(rows, cols);
930    let warmups = vec![first_valid; rows];
931    init_matrix_prefixes(&mut upper_matrix, cols, &warmups);
932    init_matrix_prefixes(&mut average_matrix, cols, &warmups);
933    init_matrix_prefixes(&mut lower_matrix, cols, &warmups);
934    init_matrix_prefixes(&mut trend_matrix, cols, &warmups);
935    init_matrix_prefixes(&mut changed_matrix, cols, &warmups);
936
937    let mut upper_guard = ManuallyDrop::new(upper_matrix);
938    let mut average_guard = ManuallyDrop::new(average_matrix);
939    let mut lower_guard = ManuallyDrop::new(lower_matrix);
940    let mut trend_guard = ManuallyDrop::new(trend_matrix);
941    let mut changed_guard = ManuallyDrop::new(changed_matrix);
942
943    let upper_mu: &mut [MaybeUninit<f64>] =
944        unsafe { std::slice::from_raw_parts_mut(upper_guard.as_mut_ptr(), upper_guard.len()) };
945    let average_mu: &mut [MaybeUninit<f64>] =
946        unsafe { std::slice::from_raw_parts_mut(average_guard.as_mut_ptr(), average_guard.len()) };
947    let lower_mu: &mut [MaybeUninit<f64>] =
948        unsafe { std::slice::from_raw_parts_mut(lower_guard.as_mut_ptr(), lower_guard.len()) };
949    let trend_mu: &mut [MaybeUninit<f64>] =
950        unsafe { std::slice::from_raw_parts_mut(trend_guard.as_mut_ptr(), trend_guard.len()) };
951    let changed_mu: &mut [MaybeUninit<f64>] =
952        unsafe { std::slice::from_raw_parts_mut(changed_guard.as_mut_ptr(), changed_guard.len()) };
953
954    let do_row = |row: usize,
955                  row_upper: &mut [MaybeUninit<f64>],
956                  row_average: &mut [MaybeUninit<f64>],
957                  row_lower: &mut [MaybeUninit<f64>],
958                  row_trend: &mut [MaybeUninit<f64>],
959                  row_changed: &mut [MaybeUninit<f64>]| {
960        let params = &combos[row];
961        let dst_upper =
962            unsafe { std::slice::from_raw_parts_mut(row_upper.as_mut_ptr() as *mut f64, cols) };
963        let dst_average =
964            unsafe { std::slice::from_raw_parts_mut(row_average.as_mut_ptr() as *mut f64, cols) };
965        let dst_lower =
966            unsafe { std::slice::from_raw_parts_mut(row_lower.as_mut_ptr() as *mut f64, cols) };
967        let dst_trend =
968            unsafe { std::slice::from_raw_parts_mut(row_trend.as_mut_ptr() as *mut f64, cols) };
969        let dst_changed =
970            unsafe { std::slice::from_raw_parts_mut(row_changed.as_mut_ptr() as *mut f64, cols) };
971
972        hypertrend_row_scalar(
973            high,
974            low,
975            source,
976            params.factor.unwrap_or(DEFAULT_FACTOR),
977            params.slope.unwrap_or(DEFAULT_SLOPE),
978            params.width_percent.unwrap_or(DEFAULT_WIDTH_PERCENT) * 0.01,
979            &atr_values,
980            dst_upper,
981            dst_average,
982            dst_lower,
983            dst_trend,
984            dst_changed,
985        );
986    };
987
988    if parallel {
989        #[cfg(not(target_arch = "wasm32"))]
990        upper_mu
991            .par_chunks_mut(cols)
992            .zip(average_mu.par_chunks_mut(cols))
993            .zip(lower_mu.par_chunks_mut(cols))
994            .zip(trend_mu.par_chunks_mut(cols))
995            .zip(changed_mu.par_chunks_mut(cols))
996            .enumerate()
997            .for_each(
998                |(row, ((((row_upper, row_average), row_lower), row_trend), row_changed))| {
999                    do_row(
1000                        row,
1001                        row_upper,
1002                        row_average,
1003                        row_lower,
1004                        row_trend,
1005                        row_changed,
1006                    )
1007                },
1008            );
1009
1010        #[cfg(target_arch = "wasm32")]
1011        for (row, ((((row_upper, row_average), row_lower), row_trend), row_changed)) in upper_mu
1012            .chunks_mut(cols)
1013            .zip(average_mu.chunks_mut(cols))
1014            .zip(lower_mu.chunks_mut(cols))
1015            .zip(trend_mu.chunks_mut(cols))
1016            .zip(changed_mu.chunks_mut(cols))
1017            .enumerate()
1018        {
1019            do_row(
1020                row,
1021                row_upper,
1022                row_average,
1023                row_lower,
1024                row_trend,
1025                row_changed,
1026            );
1027        }
1028    } else {
1029        for (row, ((((row_upper, row_average), row_lower), row_trend), row_changed)) in upper_mu
1030            .chunks_mut(cols)
1031            .zip(average_mu.chunks_mut(cols))
1032            .zip(lower_mu.chunks_mut(cols))
1033            .zip(trend_mu.chunks_mut(cols))
1034            .zip(changed_mu.chunks_mut(cols))
1035            .enumerate()
1036        {
1037            do_row(
1038                row,
1039                row_upper,
1040                row_average,
1041                row_lower,
1042                row_trend,
1043                row_changed,
1044            );
1045        }
1046    }
1047
1048    let upper = unsafe {
1049        Vec::from_raw_parts(
1050            upper_guard.as_mut_ptr() as *mut f64,
1051            total,
1052            upper_guard.capacity(),
1053        )
1054    };
1055    let average = unsafe {
1056        Vec::from_raw_parts(
1057            average_guard.as_mut_ptr() as *mut f64,
1058            total,
1059            average_guard.capacity(),
1060        )
1061    };
1062    let lower = unsafe {
1063        Vec::from_raw_parts(
1064            lower_guard.as_mut_ptr() as *mut f64,
1065            total,
1066            lower_guard.capacity(),
1067        )
1068    };
1069    let trend = unsafe {
1070        Vec::from_raw_parts(
1071            trend_guard.as_mut_ptr() as *mut f64,
1072            total,
1073            trend_guard.capacity(),
1074        )
1075    };
1076    let changed = unsafe {
1077        Vec::from_raw_parts(
1078            changed_guard.as_mut_ptr() as *mut f64,
1079            total,
1080            changed_guard.capacity(),
1081        )
1082    };
1083
1084    Ok(HyperTrendBatchOutput {
1085        upper,
1086        average,
1087        lower,
1088        trend,
1089        changed,
1090        combos,
1091        rows,
1092        cols,
1093    })
1094}
1095
1096fn hypertrend_batch_inner_into(
1097    high: &[f64],
1098    low: &[f64],
1099    source: &[f64],
1100    sweep: &HyperTrendBatchRange,
1101    kernel: Kernel,
1102    parallel: bool,
1103    out_upper: &mut [f64],
1104    out_average: &mut [f64],
1105    out_lower: &mut [f64],
1106    out_trend: &mut [f64],
1107    out_changed: &mut [f64],
1108) -> Result<Vec<HyperTrendParams>, HyperTrendError> {
1109    validate_lengths(high, low, source)?;
1110    let combos = expand_grid_hypertrend(sweep)?;
1111    for params in &combos {
1112        validate_params(
1113            params.factor.unwrap_or(DEFAULT_FACTOR),
1114            params.slope.unwrap_or(DEFAULT_SLOPE),
1115            params.width_percent.unwrap_or(DEFAULT_WIDTH_PERCENT),
1116        )?;
1117    }
1118
1119    let rows = combos.len();
1120    let cols = source.len();
1121    let total = rows
1122        .checked_mul(cols)
1123        .ok_or(HyperTrendError::OutputLengthMismatch {
1124            expected: usize::MAX,
1125            got: 0,
1126        })?;
1127    if out_upper.len() != total
1128        || out_average.len() != total
1129        || out_lower.len() != total
1130        || out_trend.len() != total
1131        || out_changed.len() != total
1132    {
1133        return Err(HyperTrendError::OutputLengthMismatch {
1134            expected: total,
1135            got: out_upper
1136                .len()
1137                .max(out_average.len())
1138                .max(out_lower.len())
1139                .max(out_trend.len())
1140                .max(out_changed.len()),
1141        });
1142    }
1143
1144    let _kernel = kernel;
1145    let atr_values = compute_atr_zeroed(high, low, source);
1146    let do_row = |row: usize,
1147                  dst_upper: &mut [f64],
1148                  dst_average: &mut [f64],
1149                  dst_lower: &mut [f64],
1150                  dst_trend: &mut [f64],
1151                  dst_changed: &mut [f64]| {
1152        let params = &combos[row];
1153        hypertrend_row_scalar(
1154            high,
1155            low,
1156            source,
1157            params.factor.unwrap_or(DEFAULT_FACTOR),
1158            params.slope.unwrap_or(DEFAULT_SLOPE),
1159            params.width_percent.unwrap_or(DEFAULT_WIDTH_PERCENT) * 0.01,
1160            &atr_values,
1161            dst_upper,
1162            dst_average,
1163            dst_lower,
1164            dst_trend,
1165            dst_changed,
1166        );
1167    };
1168
1169    if parallel {
1170        #[cfg(not(target_arch = "wasm32"))]
1171        out_upper
1172            .par_chunks_mut(cols)
1173            .zip(out_average.par_chunks_mut(cols))
1174            .zip(out_lower.par_chunks_mut(cols))
1175            .zip(out_trend.par_chunks_mut(cols))
1176            .zip(out_changed.par_chunks_mut(cols))
1177            .enumerate()
1178            .for_each(
1179                |(row, ((((dst_upper, dst_average), dst_lower), dst_trend), dst_changed))| {
1180                    do_row(
1181                        row,
1182                        dst_upper,
1183                        dst_average,
1184                        dst_lower,
1185                        dst_trend,
1186                        dst_changed,
1187                    )
1188                },
1189            );
1190
1191        #[cfg(target_arch = "wasm32")]
1192        for (row, ((((dst_upper, dst_average), dst_lower), dst_trend), dst_changed)) in out_upper
1193            .chunks_mut(cols)
1194            .zip(out_average.chunks_mut(cols))
1195            .zip(out_lower.chunks_mut(cols))
1196            .zip(out_trend.chunks_mut(cols))
1197            .zip(out_changed.chunks_mut(cols))
1198            .enumerate()
1199        {
1200            do_row(
1201                row,
1202                dst_upper,
1203                dst_average,
1204                dst_lower,
1205                dst_trend,
1206                dst_changed,
1207            );
1208        }
1209    } else {
1210        for (row, ((((dst_upper, dst_average), dst_lower), dst_trend), dst_changed)) in out_upper
1211            .chunks_mut(cols)
1212            .zip(out_average.chunks_mut(cols))
1213            .zip(out_lower.chunks_mut(cols))
1214            .zip(out_trend.chunks_mut(cols))
1215            .zip(out_changed.chunks_mut(cols))
1216            .enumerate()
1217        {
1218            do_row(
1219                row,
1220                dst_upper,
1221                dst_average,
1222                dst_lower,
1223                dst_trend,
1224                dst_changed,
1225            );
1226        }
1227    }
1228
1229    Ok(combos)
1230}
1231
1232#[cfg(feature = "python")]
1233#[pyfunction(name = "hypertrend")]
1234#[pyo3(signature = (high, low, source, factor=DEFAULT_FACTOR, slope=DEFAULT_SLOPE, width_percent=DEFAULT_WIDTH_PERCENT, kernel=None))]
1235pub fn hypertrend_py<'py>(
1236    py: Python<'py>,
1237    high: PyReadonlyArray1<'py, f64>,
1238    low: PyReadonlyArray1<'py, f64>,
1239    source: PyReadonlyArray1<'py, f64>,
1240    factor: f64,
1241    slope: f64,
1242    width_percent: f64,
1243    kernel: Option<&str>,
1244) -> PyResult<(
1245    Bound<'py, PyArray1<f64>>,
1246    Bound<'py, PyArray1<f64>>,
1247    Bound<'py, PyArray1<f64>>,
1248    Bound<'py, PyArray1<f64>>,
1249    Bound<'py, PyArray1<f64>>,
1250)> {
1251    let high = high.as_slice()?;
1252    let low = low.as_slice()?;
1253    let source = source.as_slice()?;
1254    let input = HyperTrendInput::from_slices(
1255        high,
1256        low,
1257        source,
1258        HyperTrendParams {
1259            factor: Some(factor),
1260            slope: Some(slope),
1261            width_percent: Some(width_percent),
1262        },
1263    );
1264    let kernel = validate_kernel(kernel, false)?;
1265    let out = py
1266        .allow_threads(|| hypertrend_with_kernel(&input, kernel))
1267        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1268    Ok((
1269        out.upper.into_pyarray(py),
1270        out.average.into_pyarray(py),
1271        out.lower.into_pyarray(py),
1272        out.trend.into_pyarray(py),
1273        out.changed.into_pyarray(py),
1274    ))
1275}
1276
1277#[cfg(feature = "python")]
1278#[pyclass(name = "HyperTrendStream")]
1279pub struct HyperTrendStreamPy {
1280    stream: HyperTrendStream,
1281}
1282
1283#[cfg(feature = "python")]
1284#[pymethods]
1285impl HyperTrendStreamPy {
1286    #[new]
1287    #[pyo3(signature = (factor=DEFAULT_FACTOR, slope=DEFAULT_SLOPE, width_percent=DEFAULT_WIDTH_PERCENT))]
1288    fn new(factor: f64, slope: f64, width_percent: f64) -> PyResult<Self> {
1289        let stream = HyperTrendStream::try_new(HyperTrendParams {
1290            factor: Some(factor),
1291            slope: Some(slope),
1292            width_percent: Some(width_percent),
1293        })
1294        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1295        Ok(Self { stream })
1296    }
1297
1298    fn update(&mut self, high: f64, low: f64, source: f64) -> Option<(f64, f64, f64, f64, f64)> {
1299        self.stream.update(high, low, source)
1300    }
1301}
1302
1303#[cfg(feature = "python")]
1304#[pyfunction(name = "hypertrend_batch")]
1305#[pyo3(signature = (high, low, source, factor_range, slope_range, width_percent_range, kernel=None))]
1306pub fn hypertrend_batch_py<'py>(
1307    py: Python<'py>,
1308    high: PyReadonlyArray1<'py, f64>,
1309    low: PyReadonlyArray1<'py, f64>,
1310    source: PyReadonlyArray1<'py, f64>,
1311    factor_range: (f64, f64, f64),
1312    slope_range: (f64, f64, f64),
1313    width_percent_range: (f64, f64, f64),
1314    kernel: Option<&str>,
1315) -> PyResult<Bound<'py, PyDict>> {
1316    let high = high.as_slice()?;
1317    let low = low.as_slice()?;
1318    let source = source.as_slice()?;
1319    let sweep = HyperTrendBatchRange {
1320        factor: factor_range,
1321        slope: slope_range,
1322        width_percent: width_percent_range,
1323    };
1324    let combos =
1325        expand_grid_hypertrend(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
1326    let rows = combos.len();
1327    let cols = source.len();
1328    let total = rows
1329        .checked_mul(cols)
1330        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
1331    let upper_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1332    let average_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1333    let lower_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1334    let trend_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1335    let changed_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1336    let out_upper = unsafe { upper_arr.as_slice_mut()? };
1337    let out_average = unsafe { average_arr.as_slice_mut()? };
1338    let out_lower = unsafe { lower_arr.as_slice_mut()? };
1339    let out_trend = unsafe { trend_arr.as_slice_mut()? };
1340    let out_changed = unsafe { changed_arr.as_slice_mut()? };
1341    let kernel = validate_kernel(kernel, true)?;
1342
1343    py.allow_threads(|| {
1344        let batch_kernel = match kernel {
1345            Kernel::Auto => detect_best_batch_kernel(),
1346            other => other,
1347        };
1348        hypertrend_batch_inner_into(
1349            high,
1350            low,
1351            source,
1352            &sweep,
1353            batch_kernel.to_non_batch(),
1354            true,
1355            out_upper,
1356            out_average,
1357            out_lower,
1358            out_trend,
1359            out_changed,
1360        )
1361    })
1362    .map_err(|e| PyValueError::new_err(e.to_string()))?;
1363
1364    let factors: Vec<f64> = combos
1365        .iter()
1366        .map(|params| params.factor.unwrap_or(DEFAULT_FACTOR))
1367        .collect();
1368    let slopes: Vec<f64> = combos
1369        .iter()
1370        .map(|params| params.slope.unwrap_or(DEFAULT_SLOPE))
1371        .collect();
1372    let width_percents: Vec<f64> = combos
1373        .iter()
1374        .map(|params| params.width_percent.unwrap_or(DEFAULT_WIDTH_PERCENT))
1375        .collect();
1376
1377    let dict = PyDict::new(py);
1378    dict.set_item("upper", upper_arr.reshape((rows, cols))?)?;
1379    dict.set_item("average", average_arr.reshape((rows, cols))?)?;
1380    dict.set_item("lower", lower_arr.reshape((rows, cols))?)?;
1381    dict.set_item("trend", trend_arr.reshape((rows, cols))?)?;
1382    dict.set_item("changed", changed_arr.reshape((rows, cols))?)?;
1383    dict.set_item("rows", rows)?;
1384    dict.set_item("cols", cols)?;
1385    dict.set_item("factors", factors.into_pyarray(py))?;
1386    dict.set_item("slopes", slopes.into_pyarray(py))?;
1387    dict.set_item("width_percents", width_percents.into_pyarray(py))?;
1388    Ok(dict)
1389}
1390
1391#[cfg(feature = "python")]
1392pub fn register_hypertrend_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
1393    m.add_function(wrap_pyfunction!(hypertrend_py, m)?)?;
1394    m.add_function(wrap_pyfunction!(hypertrend_batch_py, m)?)?;
1395    m.add_class::<HyperTrendStreamPy>()?;
1396    Ok(())
1397}
1398
1399#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1400#[derive(Debug, Clone, Serialize, Deserialize)]
1401struct HyperTrendJsOutput {
1402    upper: Vec<f64>,
1403    average: Vec<f64>,
1404    lower: Vec<f64>,
1405    trend: Vec<f64>,
1406    changed: Vec<f64>,
1407}
1408
1409#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1410#[derive(Debug, Clone, Serialize, Deserialize)]
1411struct HyperTrendBatchConfig {
1412    factor_range: Vec<f64>,
1413    slope_range: Vec<f64>,
1414    width_percent_range: Vec<f64>,
1415}
1416
1417#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1418#[derive(Debug, Clone, Serialize, Deserialize)]
1419struct HyperTrendBatchJsOutput {
1420    upper: Vec<f64>,
1421    average: Vec<f64>,
1422    lower: Vec<f64>,
1423    trend: Vec<f64>,
1424    changed: Vec<f64>,
1425    rows: usize,
1426    cols: usize,
1427    combos: Vec<HyperTrendParams>,
1428}
1429
1430#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1431#[wasm_bindgen(js_name = "hypertrend")]
1432pub fn hypertrend_js(
1433    high: &[f64],
1434    low: &[f64],
1435    source: &[f64],
1436    factor: f64,
1437    slope: f64,
1438    width_percent: f64,
1439) -> Result<JsValue, JsValue> {
1440    let input = HyperTrendInput::from_slices(
1441        high,
1442        low,
1443        source,
1444        HyperTrendParams {
1445            factor: Some(factor),
1446            slope: Some(slope),
1447            width_percent: Some(width_percent),
1448        },
1449    );
1450    let out = hypertrend(&input).map_err(|e| JsValue::from_str(&e.to_string()))?;
1451    serde_wasm_bindgen::to_value(&HyperTrendJsOutput {
1452        upper: out.upper,
1453        average: out.average,
1454        lower: out.lower,
1455        trend: out.trend,
1456        changed: out.changed,
1457    })
1458    .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
1459}
1460
1461#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1462#[wasm_bindgen]
1463pub fn hypertrend_into(
1464    high_ptr: *const f64,
1465    low_ptr: *const f64,
1466    source_ptr: *const f64,
1467    out_ptr: *mut f64,
1468    len: usize,
1469    factor: f64,
1470    slope: f64,
1471    width_percent: f64,
1472) -> Result<(), JsValue> {
1473    if high_ptr.is_null() || low_ptr.is_null() || source_ptr.is_null() || out_ptr.is_null() {
1474        return Err(JsValue::from_str("null pointer passed to hypertrend_into"));
1475    }
1476
1477    unsafe {
1478        let high = std::slice::from_raw_parts(high_ptr, len);
1479        let low = std::slice::from_raw_parts(low_ptr, len);
1480        let source = std::slice::from_raw_parts(source_ptr, len);
1481        let out = std::slice::from_raw_parts_mut(out_ptr, len * 5);
1482        let (out_upper, rest) = out.split_at_mut(len);
1483        let (out_average, rest) = rest.split_at_mut(len);
1484        let (out_lower, rest) = rest.split_at_mut(len);
1485        let (out_trend, out_changed) = rest.split_at_mut(len);
1486        let input = HyperTrendInput::from_slices(
1487            high,
1488            low,
1489            source,
1490            HyperTrendParams {
1491                factor: Some(factor),
1492                slope: Some(slope),
1493                width_percent: Some(width_percent),
1494            },
1495        );
1496        hypertrend_into_slice(
1497            out_upper,
1498            out_average,
1499            out_lower,
1500            out_trend,
1501            out_changed,
1502            &input,
1503            Kernel::Auto,
1504        )
1505        .map_err(|e| JsValue::from_str(&e.to_string()))
1506    }
1507}
1508
1509#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1510#[wasm_bindgen(js_name = "hypertrend_into_host")]
1511pub fn hypertrend_into_host(
1512    high: &[f64],
1513    low: &[f64],
1514    source: &[f64],
1515    out_ptr: *mut f64,
1516    factor: f64,
1517    slope: f64,
1518    width_percent: f64,
1519) -> Result<(), JsValue> {
1520    if out_ptr.is_null() {
1521        return Err(JsValue::from_str(
1522            "null pointer passed to hypertrend_into_host",
1523        ));
1524    }
1525
1526    unsafe {
1527        let out = std::slice::from_raw_parts_mut(out_ptr, source.len() * 5);
1528        let (out_upper, rest) = out.split_at_mut(source.len());
1529        let (out_average, rest) = rest.split_at_mut(source.len());
1530        let (out_lower, rest) = rest.split_at_mut(source.len());
1531        let (out_trend, out_changed) = rest.split_at_mut(source.len());
1532        let input = HyperTrendInput::from_slices(
1533            high,
1534            low,
1535            source,
1536            HyperTrendParams {
1537                factor: Some(factor),
1538                slope: Some(slope),
1539                width_percent: Some(width_percent),
1540            },
1541        );
1542        hypertrend_into_slice(
1543            out_upper,
1544            out_average,
1545            out_lower,
1546            out_trend,
1547            out_changed,
1548            &input,
1549            Kernel::Auto,
1550        )
1551        .map_err(|e| JsValue::from_str(&e.to_string()))
1552    }
1553}
1554
1555#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1556#[wasm_bindgen]
1557pub fn hypertrend_alloc(len: usize) -> *mut f64 {
1558    let mut buf = vec![0.0_f64; len * 5];
1559    let ptr = buf.as_mut_ptr();
1560    std::mem::forget(buf);
1561    ptr
1562}
1563
1564#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1565#[wasm_bindgen]
1566pub fn hypertrend_free(ptr: *mut f64, len: usize) {
1567    if ptr.is_null() {
1568        return;
1569    }
1570    unsafe {
1571        let _ = Vec::from_raw_parts(ptr, len * 5, len * 5);
1572    }
1573}
1574
1575#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1576#[wasm_bindgen(js_name = "hypertrend_batch")]
1577pub fn hypertrend_batch_js(
1578    high: &[f64],
1579    low: &[f64],
1580    source: &[f64],
1581    config: JsValue,
1582) -> Result<JsValue, JsValue> {
1583    let config: HyperTrendBatchConfig = serde_wasm_bindgen::from_value(config)
1584        .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
1585    if config.factor_range.len() != 3
1586        || config.slope_range.len() != 3
1587        || config.width_percent_range.len() != 3
1588    {
1589        return Err(JsValue::from_str(
1590            "Invalid config: ranges must have exactly 3 elements [start, end, step]",
1591        ));
1592    }
1593
1594    let sweep = HyperTrendBatchRange {
1595        factor: (
1596            config.factor_range[0],
1597            config.factor_range[1],
1598            config.factor_range[2],
1599        ),
1600        slope: (
1601            config.slope_range[0],
1602            config.slope_range[1],
1603            config.slope_range[2],
1604        ),
1605        width_percent: (
1606            config.width_percent_range[0],
1607            config.width_percent_range[1],
1608            config.width_percent_range[2],
1609        ),
1610    };
1611    let batch = hypertrend_batch_slice(high, low, source, &sweep, Kernel::Scalar)
1612        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1613    serde_wasm_bindgen::to_value(&HyperTrendBatchJsOutput {
1614        upper: batch.upper,
1615        average: batch.average,
1616        lower: batch.lower,
1617        trend: batch.trend,
1618        changed: batch.changed,
1619        rows: batch.rows,
1620        cols: batch.cols,
1621        combos: batch.combos,
1622    })
1623    .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
1624}
1625
1626#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1627#[wasm_bindgen]
1628pub fn hypertrend_batch_into(
1629    high_ptr: *const f64,
1630    low_ptr: *const f64,
1631    source_ptr: *const f64,
1632    upper_ptr: *mut f64,
1633    average_ptr: *mut f64,
1634    lower_ptr: *mut f64,
1635    trend_ptr: *mut f64,
1636    changed_ptr: *mut f64,
1637    len: usize,
1638    factor_start: f64,
1639    factor_end: f64,
1640    factor_step: f64,
1641    slope_start: f64,
1642    slope_end: f64,
1643    slope_step: f64,
1644    width_percent_start: f64,
1645    width_percent_end: f64,
1646    width_percent_step: f64,
1647) -> Result<usize, JsValue> {
1648    if high_ptr.is_null()
1649        || low_ptr.is_null()
1650        || source_ptr.is_null()
1651        || upper_ptr.is_null()
1652        || average_ptr.is_null()
1653        || lower_ptr.is_null()
1654        || trend_ptr.is_null()
1655        || changed_ptr.is_null()
1656    {
1657        return Err(JsValue::from_str(
1658            "null pointer passed to hypertrend_batch_into",
1659        ));
1660    }
1661
1662    unsafe {
1663        let high = std::slice::from_raw_parts(high_ptr, len);
1664        let low = std::slice::from_raw_parts(low_ptr, len);
1665        let source = std::slice::from_raw_parts(source_ptr, len);
1666        let sweep = HyperTrendBatchRange {
1667            factor: (factor_start, factor_end, factor_step),
1668            slope: (slope_start, slope_end, slope_step),
1669            width_percent: (width_percent_start, width_percent_end, width_percent_step),
1670        };
1671        let combos =
1672            expand_grid_hypertrend(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
1673        let rows = combos.len();
1674        let total = rows
1675            .checked_mul(len)
1676            .ok_or_else(|| JsValue::from_str("rows*cols overflow"))?;
1677        let upper = std::slice::from_raw_parts_mut(upper_ptr, total);
1678        let average = std::slice::from_raw_parts_mut(average_ptr, total);
1679        let lower = std::slice::from_raw_parts_mut(lower_ptr, total);
1680        let trend = std::slice::from_raw_parts_mut(trend_ptr, total);
1681        let changed = std::slice::from_raw_parts_mut(changed_ptr, total);
1682        hypertrend_batch_inner_into(
1683            high,
1684            low,
1685            source,
1686            &sweep,
1687            Kernel::Scalar,
1688            false,
1689            upper,
1690            average,
1691            lower,
1692            trend,
1693            changed,
1694        )
1695        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1696        Ok(rows)
1697    }
1698}
1699
1700#[cfg(test)]
1701mod tests {
1702    use super::*;
1703    use crate::indicators::dispatch::{
1704        compute_cpu_batch, IndicatorBatchRequest, IndicatorDataRef, IndicatorParamSet, ParamKV,
1705        ParamValue,
1706    };
1707
1708    fn assert_close(a: &[f64], b: &[f64], tol: f64) {
1709        assert_eq!(a.len(), b.len());
1710        for i in 0..a.len() {
1711            let lhs = a[i];
1712            let rhs = b[i];
1713            if lhs.is_nan() || rhs.is_nan() {
1714                assert!(
1715                    lhs.is_nan() && rhs.is_nan(),
1716                    "nan mismatch at {i}: {lhs} vs {rhs}"
1717                );
1718            } else {
1719                assert!(
1720                    (lhs - rhs).abs() <= tol,
1721                    "mismatch at {i}: {lhs} vs {rhs} with tol {tol}"
1722                );
1723            }
1724        }
1725    }
1726
1727    fn sample_hls(len: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
1728        let mut high = Vec::with_capacity(len);
1729        let mut low = Vec::with_capacity(len);
1730        let mut source = Vec::with_capacity(len);
1731
1732        for i in 0..len {
1733            let base = 100.0 + i as f64 * 0.17 + (i as f64 * 0.031).sin() * 2.75;
1734            let spread = 1.0 + (i as f64 * 0.07).cos().abs() * 1.8;
1735            let src = base + (i as f64 * 0.11).sin() * 0.8;
1736            high.push(base + spread);
1737            low.push(base - spread);
1738            source.push(src);
1739        }
1740
1741        (high, low, source)
1742    }
1743
1744    fn check_output_contract(kernel: Kernel) {
1745        let (high, low, source) = sample_hls(320);
1746        let input = HyperTrendInput::from_slices(
1747            &high,
1748            &low,
1749            &source,
1750            HyperTrendParams {
1751                factor: Some(5.0),
1752                slope: Some(14.0),
1753                width_percent: Some(80.0),
1754            },
1755        );
1756        let out = hypertrend_with_kernel(&input, kernel).expect("indicator");
1757        assert_eq!(out.upper.len(), source.len());
1758        assert_eq!(out.average.len(), source.len());
1759        assert_eq!(out.lower.len(), source.len());
1760        assert_eq!(out.trend.len(), source.len());
1761        assert_eq!(out.changed.len(), source.len());
1762        assert!(out.average.iter().any(|v| v.is_finite()));
1763        assert!(out
1764            .upper
1765            .iter()
1766            .zip(&out.lower)
1767            .all(|(u, l)| (u.is_nan() && l.is_nan()) || (*u >= *l)));
1768    }
1769
1770    fn check_into_matches_api(kernel: Kernel) {
1771        let (high, low, source) = sample_hls(240);
1772        let input = HyperTrendInput::from_slices(
1773            &high,
1774            &low,
1775            &source,
1776            HyperTrendParams {
1777                factor: Some(4.5),
1778                slope: Some(10.0),
1779                width_percent: Some(60.0),
1780            },
1781        );
1782        let baseline = hypertrend_with_kernel(&input, kernel).expect("baseline");
1783        let mut upper = vec![0.0; source.len()];
1784        let mut average = vec![0.0; source.len()];
1785        let mut lower = vec![0.0; source.len()];
1786        let mut trend = vec![0.0; source.len()];
1787        let mut changed = vec![0.0; source.len()];
1788        hypertrend_into_slice(
1789            &mut upper,
1790            &mut average,
1791            &mut lower,
1792            &mut trend,
1793            &mut changed,
1794            &input,
1795            kernel,
1796        )
1797        .expect("into");
1798
1799        assert_close(&baseline.upper, &upper, 1e-12);
1800        assert_close(&baseline.average, &average, 1e-12);
1801        assert_close(&baseline.lower, &lower, 1e-12);
1802        assert_close(&baseline.trend, &trend, 1e-12);
1803        assert_close(&baseline.changed, &changed, 1e-12);
1804    }
1805
1806    fn check_stream_matches_batch() {
1807        let (high, low, source) = sample_hls(260);
1808        let input = HyperTrendInput::from_slices(
1809            &high,
1810            &low,
1811            &source,
1812            HyperTrendParams {
1813                factor: Some(4.0),
1814                slope: Some(12.0),
1815                width_percent: Some(55.0),
1816            },
1817        );
1818        let batch = hypertrend(&input).expect("batch");
1819        let mut stream = HyperTrendStream::try_new(HyperTrendParams {
1820            factor: Some(4.0),
1821            slope: Some(12.0),
1822            width_percent: Some(55.0),
1823        })
1824        .expect("stream");
1825
1826        let mut upper = vec![f64::NAN; source.len()];
1827        let mut average = vec![f64::NAN; source.len()];
1828        let mut lower = vec![f64::NAN; source.len()];
1829        let mut trend = vec![f64::NAN; source.len()];
1830        let mut changed = vec![f64::NAN; source.len()];
1831
1832        for i in 0..source.len() {
1833            if let Some((u, a, l, t, c)) = stream.update(high[i], low[i], source[i]) {
1834                upper[i] = u;
1835                average[i] = a;
1836                lower[i] = l;
1837                trend[i] = t;
1838                changed[i] = c;
1839            }
1840        }
1841
1842        assert_close(&batch.upper, &upper, 1e-12);
1843        assert_close(&batch.average, &average, 1e-12);
1844        assert_close(&batch.lower, &lower, 1e-12);
1845        assert_close(&batch.trend, &trend, 1e-12);
1846        assert_close(&batch.changed, &changed, 1e-12);
1847    }
1848
1849    fn check_batch_single_matches_single(kernel: Kernel) {
1850        let (high, low, source) = sample_hls(180);
1851        let batch = hypertrend_batch_with_kernel(
1852            &high,
1853            &low,
1854            &source,
1855            &HyperTrendBatchRange {
1856                factor: (5.0, 5.0, 0.0),
1857                slope: (14.0, 14.0, 0.0),
1858                width_percent: (80.0, 80.0, 0.0),
1859            },
1860            kernel,
1861        )
1862        .expect("batch");
1863        let single = hypertrend(&HyperTrendInput::from_slices(
1864            &high,
1865            &low,
1866            &source,
1867            HyperTrendParams {
1868                factor: Some(5.0),
1869                slope: Some(14.0),
1870                width_percent: Some(80.0),
1871            },
1872        ))
1873        .expect("single");
1874
1875        assert_eq!(batch.rows, 1);
1876        assert_eq!(batch.cols, source.len());
1877        assert_close(&batch.upper[..source.len()], &single.upper, 1e-12);
1878        assert_close(&batch.average[..source.len()], &single.average, 1e-12);
1879        assert_close(&batch.lower[..source.len()], &single.lower, 1e-12);
1880        assert_close(&batch.trend[..source.len()], &single.trend, 1e-12);
1881        assert_close(&batch.changed[..source.len()], &single.changed, 1e-12);
1882    }
1883
1884    #[test]
1885    fn hypertrend_invalid_params() {
1886        let (high, low, source) = sample_hls(64);
1887
1888        let err = hypertrend(&HyperTrendInput::from_slices(
1889            &high,
1890            &low,
1891            &source,
1892            HyperTrendParams {
1893                factor: Some(0.0),
1894                slope: Some(14.0),
1895                width_percent: Some(80.0),
1896            },
1897        ))
1898        .expect_err("invalid factor");
1899        assert!(matches!(err, HyperTrendError::InvalidFactor { .. }));
1900
1901        let err = hypertrend(&HyperTrendInput::from_slices(
1902            &high,
1903            &low,
1904            &source,
1905            HyperTrendParams {
1906                factor: Some(5.0),
1907                slope: Some(0.0),
1908                width_percent: Some(80.0),
1909            },
1910        ))
1911        .expect_err("invalid slope");
1912        assert!(matches!(err, HyperTrendError::InvalidSlope { .. }));
1913
1914        let err = hypertrend(&HyperTrendInput::from_slices(
1915            &high,
1916            &low,
1917            &source,
1918            HyperTrendParams {
1919                factor: Some(5.0),
1920                slope: Some(14.0),
1921                width_percent: Some(120.0),
1922            },
1923        ))
1924        .expect_err("invalid width");
1925        assert!(matches!(err, HyperTrendError::InvalidWidthPercent { .. }));
1926    }
1927
1928    #[test]
1929    fn hypertrend_output_contract() {
1930        check_output_contract(Kernel::Auto);
1931        check_output_contract(Kernel::Scalar);
1932    }
1933
1934    #[test]
1935    fn hypertrend_into_matches_api() {
1936        check_into_matches_api(Kernel::Auto);
1937        check_into_matches_api(Kernel::Scalar);
1938    }
1939
1940    #[test]
1941    fn hypertrend_stream_matches_batch() {
1942        check_stream_matches_batch();
1943    }
1944
1945    #[test]
1946    fn hypertrend_batch_single_matches_single() {
1947        check_batch_single_matches_single(Kernel::Auto);
1948    }
1949
1950    #[test]
1951    fn hypertrend_dispatch_matches_direct() {
1952        let (high, low, source) = sample_hls(160);
1953        let combo = [
1954            ParamKV {
1955                key: "factor",
1956                value: ParamValue::Float(5.0),
1957            },
1958            ParamKV {
1959                key: "slope",
1960                value: ParamValue::Float(14.0),
1961            },
1962            ParamKV {
1963                key: "width_percent",
1964                value: ParamValue::Float(80.0),
1965            },
1966        ];
1967        let combos = [IndicatorParamSet { params: &combo }];
1968        let req = IndicatorBatchRequest {
1969            indicator_id: "hypertrend",
1970            output_id: Some("average"),
1971            data: IndicatorDataRef::Ohlc {
1972                open: &source,
1973                high: &high,
1974                low: &low,
1975                close: &source,
1976            },
1977            combos: &combos,
1978            kernel: Kernel::Auto,
1979        };
1980
1981        let batch = compute_cpu_batch(req).expect("dispatch");
1982        assert_eq!(batch.rows, 1);
1983        assert_eq!(batch.cols, source.len());
1984
1985        let direct = hypertrend(&HyperTrendInput::from_slices(
1986            &high,
1987            &low,
1988            &source,
1989            HyperTrendParams {
1990                factor: Some(5.0),
1991                slope: Some(14.0),
1992                width_percent: Some(80.0),
1993            },
1994        ))
1995        .expect("direct");
1996        let row = &batch.values_f64.as_ref().expect("f64 output")[0..source.len()];
1997        assert_close(row, &direct.average, 1e-12);
1998    }
1999}