Skip to main content

vector_ta/indicators/
ict_propulsion_block.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, PyList};
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, make_uninit_matrix,
19};
20#[cfg(feature = "python")]
21use crate::utilities::kernel_validation::validate_kernel;
22
23use std::collections::VecDeque;
24use std::mem::{ManuallyDrop, MaybeUninit};
25use thiserror::Error;
26
27const DEFAULT_SWING_LENGTH: usize = 3;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[cfg_attr(
31    all(target_arch = "wasm32", feature = "wasm"),
32    derive(Serialize, Deserialize)
33)]
34#[cfg_attr(
35    all(target_arch = "wasm32", feature = "wasm"),
36    serde(rename_all = "snake_case")
37)]
38pub enum IctPropulsionBlockMitigationPrice {
39    Close,
40    Wick,
41}
42
43impl Default for IctPropulsionBlockMitigationPrice {
44    fn default() -> Self {
45        Self::Close
46    }
47}
48
49impl IctPropulsionBlockMitigationPrice {
50    #[inline]
51    fn as_str(self) -> &'static str {
52        match self {
53            Self::Close => "close",
54            Self::Wick => "wick",
55        }
56    }
57}
58
59#[derive(Debug, Clone)]
60pub enum IctPropulsionBlockData<'a> {
61    Candles(&'a Candles),
62    Slices {
63        open: &'a [f64],
64        high: &'a [f64],
65        low: &'a [f64],
66        close: &'a [f64],
67    },
68}
69
70#[derive(Debug, Clone)]
71pub struct IctPropulsionBlockOutput {
72    pub bullish_high: Vec<f64>,
73    pub bullish_low: Vec<f64>,
74    pub bullish_kind: Vec<f64>,
75    pub bullish_active: Vec<f64>,
76    pub bullish_mitigated: Vec<f64>,
77    pub bullish_new: Vec<f64>,
78    pub bearish_high: Vec<f64>,
79    pub bearish_low: Vec<f64>,
80    pub bearish_kind: Vec<f64>,
81    pub bearish_active: Vec<f64>,
82    pub bearish_mitigated: Vec<f64>,
83    pub bearish_new: Vec<f64>,
84}
85
86#[derive(Debug, Clone)]
87#[cfg_attr(
88    all(target_arch = "wasm32", feature = "wasm"),
89    derive(Serialize, Deserialize)
90)]
91pub struct IctPropulsionBlockParams {
92    pub swing_length: Option<usize>,
93    pub mitigation_price: Option<IctPropulsionBlockMitigationPrice>,
94}
95
96impl Default for IctPropulsionBlockParams {
97    fn default() -> Self {
98        Self {
99            swing_length: Some(DEFAULT_SWING_LENGTH),
100            mitigation_price: Some(IctPropulsionBlockMitigationPrice::Close),
101        }
102    }
103}
104
105#[derive(Debug, Clone)]
106pub struct IctPropulsionBlockInput<'a> {
107    pub data: IctPropulsionBlockData<'a>,
108    pub params: IctPropulsionBlockParams,
109}
110
111impl<'a> IctPropulsionBlockInput<'a> {
112    #[inline]
113    pub fn from_candles(candles: &'a Candles, params: IctPropulsionBlockParams) -> Self {
114        Self {
115            data: IctPropulsionBlockData::Candles(candles),
116            params,
117        }
118    }
119
120    #[inline]
121    pub fn from_slices(
122        open: &'a [f64],
123        high: &'a [f64],
124        low: &'a [f64],
125        close: &'a [f64],
126        params: IctPropulsionBlockParams,
127    ) -> Self {
128        Self {
129            data: IctPropulsionBlockData::Slices {
130                open,
131                high,
132                low,
133                close,
134            },
135            params,
136        }
137    }
138
139    #[inline]
140    pub fn with_default_candles(candles: &'a Candles) -> Self {
141        Self::from_candles(candles, IctPropulsionBlockParams::default())
142    }
143
144    #[inline]
145    pub fn get_swing_length(&self) -> usize {
146        self.params.swing_length.unwrap_or(DEFAULT_SWING_LENGTH)
147    }
148
149    #[inline]
150    pub fn get_mitigation_price(&self) -> IctPropulsionBlockMitigationPrice {
151        self.params
152            .mitigation_price
153            .unwrap_or(IctPropulsionBlockMitigationPrice::Close)
154    }
155
156    #[inline]
157    pub fn as_refs(&'a self) -> (&'a [f64], &'a [f64], &'a [f64], &'a [f64]) {
158        match &self.data {
159            IctPropulsionBlockData::Candles(candles) => {
160                (&candles.open, &candles.high, &candles.low, &candles.close)
161            }
162            IctPropulsionBlockData::Slices {
163                open,
164                high,
165                low,
166                close,
167            } => (*open, *high, *low, *close),
168        }
169    }
170}
171
172#[derive(Clone, Debug)]
173pub struct IctPropulsionBlockBuilder {
174    swing_length: Option<usize>,
175    mitigation_price: Option<IctPropulsionBlockMitigationPrice>,
176    kernel: Kernel,
177}
178
179impl Default for IctPropulsionBlockBuilder {
180    fn default() -> Self {
181        Self {
182            swing_length: None,
183            mitigation_price: None,
184            kernel: Kernel::Auto,
185        }
186    }
187}
188
189impl IctPropulsionBlockBuilder {
190    #[inline]
191    pub fn new() -> Self {
192        Self::default()
193    }
194
195    #[inline]
196    pub fn swing_length(mut self, value: usize) -> Self {
197        self.swing_length = Some(value);
198        self
199    }
200
201    #[inline]
202    pub fn mitigation_price(mut self, value: IctPropulsionBlockMitigationPrice) -> Self {
203        self.mitigation_price = Some(value);
204        self
205    }
206
207    #[inline]
208    pub fn kernel(mut self, value: Kernel) -> Self {
209        self.kernel = value;
210        self
211    }
212
213    #[inline]
214    pub fn apply(
215        self,
216        candles: &Candles,
217    ) -> Result<IctPropulsionBlockOutput, IctPropulsionBlockError> {
218        let input = IctPropulsionBlockInput::from_candles(
219            candles,
220            IctPropulsionBlockParams {
221                swing_length: self.swing_length,
222                mitigation_price: self.mitigation_price,
223            },
224        );
225        ict_propulsion_block_with_kernel(&input, self.kernel)
226    }
227
228    #[inline]
229    pub fn apply_slices(
230        self,
231        open: &[f64],
232        high: &[f64],
233        low: &[f64],
234        close: &[f64],
235    ) -> Result<IctPropulsionBlockOutput, IctPropulsionBlockError> {
236        let input = IctPropulsionBlockInput::from_slices(
237            open,
238            high,
239            low,
240            close,
241            IctPropulsionBlockParams {
242                swing_length: self.swing_length,
243                mitigation_price: self.mitigation_price,
244            },
245        );
246        ict_propulsion_block_with_kernel(&input, self.kernel)
247    }
248
249    #[inline]
250    pub fn into_stream(self) -> Result<IctPropulsionBlockStream, IctPropulsionBlockError> {
251        IctPropulsionBlockStream::try_new(IctPropulsionBlockParams {
252            swing_length: self.swing_length,
253            mitigation_price: self.mitigation_price,
254        })
255    }
256}
257
258#[derive(Debug, Error)]
259pub enum IctPropulsionBlockError {
260    #[error("ict_propulsion_block: Empty input data.")]
261    EmptyInputData,
262    #[error(
263        "ict_propulsion_block: Input length mismatch: open={open}, high={high}, low={low}, close={close}"
264    )]
265    DataLengthMismatch {
266        open: usize,
267        high: usize,
268        low: usize,
269        close: usize,
270    },
271    #[error("ict_propulsion_block: All input values are invalid.")]
272    AllValuesNaN,
273    #[error("ict_propulsion_block: Invalid swing_length: {swing_length}")]
274    InvalidSwingLength { swing_length: usize },
275    #[error("ict_propulsion_block: Invalid mitigation_price: {mitigation_price}")]
276    InvalidMitigationPrice { mitigation_price: String },
277    #[error("ict_propulsion_block: Output length mismatch: expected={expected}, got={got}")]
278    OutputLengthMismatch { expected: usize, got: usize },
279    #[error("ict_propulsion_block: Invalid range: start={start}, end={end}, step={step}")]
280    InvalidRange {
281        start: String,
282        end: String,
283        step: String,
284    },
285    #[error("ict_propulsion_block: Invalid kernel for batch: {0:?}")]
286    InvalidKernelForBatch(Kernel),
287}
288
289#[derive(Clone, Copy, Debug)]
290struct SwingState {
291    value: f64,
292    index: usize,
293    cross: bool,
294}
295
296impl SwingState {
297    #[inline]
298    fn na() -> Self {
299        Self {
300            value: f64::NAN,
301            index: 0,
302            cross: false,
303        }
304    }
305
306    #[inline]
307    fn is_valid(self) -> bool {
308        self.value.is_finite()
309    }
310}
311
312#[derive(Clone, Copy, Debug)]
313struct BlockSeed {
314    index: usize,
315    open: f64,
316    high: f64,
317    low: f64,
318    close: f64,
319}
320
321#[derive(Clone, Copy, Debug)]
322struct BlockState {
323    start_index: usize,
324    end_index: usize,
325    confirmed_index: usize,
326    open: f64,
327    high: f64,
328    low: f64,
329    close: f64,
330    is_propulsion: bool,
331    is_active: bool,
332    is_mitigated: bool,
333}
334
335impl BlockState {
336    #[inline]
337    fn new(seed: BlockSeed, confirmed_index: usize, is_propulsion: bool) -> Self {
338        Self {
339            start_index: seed.index,
340            end_index: confirmed_index,
341            confirmed_index,
342            open: seed.open,
343            high: seed.high,
344            low: seed.low,
345            close: seed.close,
346            is_propulsion,
347            is_active: true,
348            is_mitigated: false,
349        }
350    }
351
352    #[inline]
353    fn kind_value(self) -> f64 {
354        if self.is_propulsion {
355            2.0
356        } else {
357            1.0
358        }
359    }
360}
361
362#[inline(always)]
363fn valid_bar(open: f64, high: f64, low: f64, close: f64) -> bool {
364    open.is_finite() && high.is_finite() && low.is_finite() && close.is_finite() && high >= low
365}
366
367#[inline(always)]
368fn validate_lengths(
369    open: &[f64],
370    high: &[f64],
371    low: &[f64],
372    close: &[f64],
373) -> Result<(), IctPropulsionBlockError> {
374    if open.is_empty() || high.is_empty() || low.is_empty() || close.is_empty() {
375        return Err(IctPropulsionBlockError::EmptyInputData);
376    }
377    if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
378        return Err(IctPropulsionBlockError::DataLengthMismatch {
379            open: open.len(),
380            high: high.len(),
381            low: low.len(),
382            close: close.len(),
383        });
384    }
385    Ok(())
386}
387
388#[inline(always)]
389fn first_valid_bar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Option<usize> {
390    (0..close.len()).find(|&i| valid_bar(open[i], high[i], low[i], close[i]))
391}
392
393#[inline(always)]
394fn validate_params(
395    swing_length: usize,
396    mitigation_price: IctPropulsionBlockMitigationPrice,
397) -> Result<(), IctPropulsionBlockError> {
398    if swing_length == 0 {
399        return Err(IctPropulsionBlockError::InvalidSwingLength { swing_length });
400    }
401    match mitigation_price {
402        IctPropulsionBlockMitigationPrice::Close | IctPropulsionBlockMitigationPrice::Wick => {}
403    }
404    Ok(())
405}
406
407#[inline(always)]
408fn push_front_limited(blocks: &mut Vec<BlockState>, block: BlockState) {
409    blocks.insert(0, block);
410    if blocks.len() > 2 {
411        blocks.truncate(2);
412    }
413}
414
415#[inline(always)]
416fn reset_deque(deque: &mut VecDeque<usize>) {
417    deque.clear();
418}
419
420#[inline(always)]
421fn select_bullish_seed(
422    current: usize,
423    swing_index: usize,
424    open: &[f64],
425    high: &[f64],
426    low: &[f64],
427    close: &[f64],
428) -> BlockSeed {
429    let mut best = BlockSeed {
430        index: current - 1,
431        open: open[current - 1],
432        high: high[current - 1],
433        low: low[current - 1],
434        close: close[current - 1],
435    };
436    let diff = current.saturating_sub(swing_index);
437    for offset in 1..diff {
438        let idx = current - offset;
439        if open[idx] > close[idx] && low[idx] <= best.low {
440            best = BlockSeed {
441                index: idx,
442                open: open[idx],
443                high: high[idx],
444                low: low[idx],
445                close: close[idx],
446            };
447        }
448    }
449    best
450}
451
452#[inline(always)]
453fn select_bearish_seed(
454    current: usize,
455    swing_index: usize,
456    open: &[f64],
457    high: &[f64],
458    low: &[f64],
459    close: &[f64],
460) -> BlockSeed {
461    let mut best = BlockSeed {
462        index: current - 1,
463        open: open[current - 1],
464        high: high[current - 1],
465        low: low[current - 1],
466        close: close[current - 1],
467    };
468    let diff = current.saturating_sub(swing_index);
469    for offset in 1..diff {
470        let idx = current - offset;
471        if open[idx] < close[idx] && high[idx] >= best.high {
472            best = BlockSeed {
473                index: idx,
474                open: open[idx],
475                high: high[idx],
476                low: low[idx],
477                close: close[idx],
478            };
479        }
480    }
481    best
482}
483
484#[inline(always)]
485fn maybe_insert_bullish_order_block(
486    blocks: &mut Vec<BlockState>,
487    seed: BlockSeed,
488    current: usize,
489) -> bool {
490    if blocks.is_empty() {
491        push_front_limited(blocks, BlockState::new(seed, current, false));
492        return true;
493    }
494
495    if blocks[0].is_mitigated
496        && blocks[0].is_propulsion
497        && blocks.len() > 1
498        && !blocks[1].is_propulsion
499    {
500        blocks[1].is_mitigated = true;
501    }
502
503    let recent = blocks[0];
504    if !(recent.is_mitigated
505        || (!recent.is_mitigated && seed.high > recent.high && seed.index > recent.start_index))
506    {
507        return false;
508    }
509
510    push_front_limited(blocks, BlockState::new(seed, current, false));
511    if blocks.len() > 1 {
512        blocks[1].is_active = false;
513        if seed.index <= blocks[1].end_index
514            && blocks[0].low <= blocks[1].high
515            && blocks[0].high > blocks[1].high
516        {
517            blocks[0].is_propulsion = true;
518        }
519    }
520    true
521}
522
523#[inline(always)]
524fn maybe_insert_bearish_order_block(
525    blocks: &mut Vec<BlockState>,
526    seed: BlockSeed,
527    current: usize,
528) -> bool {
529    if blocks.is_empty() {
530        push_front_limited(blocks, BlockState::new(seed, current, false));
531        return true;
532    }
533
534    if blocks[0].is_mitigated
535        && blocks[0].is_propulsion
536        && blocks.len() > 1
537        && !blocks[1].is_propulsion
538    {
539        blocks[1].is_mitigated = true;
540    }
541
542    let recent = blocks[0];
543    if !(recent.is_mitigated
544        || (!recent.is_mitigated && seed.low < recent.low && seed.index > recent.start_index))
545    {
546        return false;
547    }
548
549    push_front_limited(blocks, BlockState::new(seed, current, false));
550    if blocks.len() > 1 {
551        blocks[1].is_active = false;
552        if seed.index <= blocks[1].end_index
553            && blocks[0].high >= blocks[1].low
554            && blocks[0].low < blocks[1].low
555        {
556            blocks[0].is_propulsion = true;
557        }
558    }
559    true
560}
561
562#[inline(always)]
563fn insert_bullish_propulsion(
564    blocks: &mut Vec<BlockState>,
565    breach_index: usize,
566    breach_high: f64,
567    current: usize,
568    open: &[f64],
569    low: &[f64],
570    close: &[f64],
571) -> bool {
572    if blocks.is_empty() {
573        return false;
574    }
575    blocks[0].is_active = false;
576    blocks[0].end_index = current;
577    let seed = BlockSeed {
578        index: breach_index,
579        open: open[breach_index],
580        high: breach_high,
581        low: low[breach_index],
582        close: close[breach_index],
583    };
584    push_front_limited(blocks, BlockState::new(seed, current, true));
585    true
586}
587
588#[inline(always)]
589fn insert_bearish_propulsion(
590    blocks: &mut Vec<BlockState>,
591    breach_index: usize,
592    breach_low: f64,
593    current: usize,
594    open: &[f64],
595    high: &[f64],
596    close: &[f64],
597) -> bool {
598    if blocks.is_empty() {
599        return false;
600    }
601    blocks[0].is_active = false;
602    blocks[0].end_index = current;
603    let seed = BlockSeed {
604        index: breach_index,
605        open: open[breach_index],
606        high: high[breach_index],
607        low: breach_low,
608        close: close[breach_index],
609    };
610    push_front_limited(blocks, BlockState::new(seed, current, true));
611    true
612}
613
614#[inline(always)]
615fn write_snapshot(
616    block: Option<&BlockState>,
617    new_flag: f64,
618    out_high: &mut [f64],
619    out_low: &mut [f64],
620    out_kind: &mut [f64],
621    out_active: &mut [f64],
622    out_mitigated: &mut [f64],
623    out_new: &mut [f64],
624    index: usize,
625) {
626    if let Some(block) = block {
627        out_high[index] = block.high;
628        out_low[index] = block.low;
629        out_kind[index] = block.kind_value();
630        out_active[index] = if block.is_active { 1.0 } else { 0.0 };
631        out_mitigated[index] = if block.is_mitigated { 1.0 } else { 0.0 };
632        out_new[index] = new_flag;
633    } else {
634        out_high[index] = f64::NAN;
635        out_low[index] = f64::NAN;
636        out_kind[index] = 0.0;
637        out_active[index] = 0.0;
638        out_mitigated[index] = 0.0;
639        out_new[index] = new_flag;
640    }
641}
642
643#[inline(always)]
644fn normalize_kernel(kernel: Kernel) -> Kernel {
645    match kernel {
646        Kernel::Auto => Kernel::Scalar,
647        other if other.is_batch() => other.to_non_batch(),
648        other => other,
649    }
650}
651
652#[allow(clippy::too_many_arguments)]
653fn ict_propulsion_block_row_scalar(
654    open: &[f64],
655    high: &[f64],
656    low: &[f64],
657    close: &[f64],
658    swing_length: usize,
659    mitigation_price: IctPropulsionBlockMitigationPrice,
660    out_bullish_high: &mut [f64],
661    out_bullish_low: &mut [f64],
662    out_bullish_kind: &mut [f64],
663    out_bullish_active: &mut [f64],
664    out_bullish_mitigated: &mut [f64],
665    out_bullish_new: &mut [f64],
666    out_bearish_high: &mut [f64],
667    out_bearish_low: &mut [f64],
668    out_bearish_kind: &mut [f64],
669    out_bearish_active: &mut [f64],
670    out_bearish_mitigated: &mut [f64],
671    out_bearish_new: &mut [f64],
672) {
673    let len = close.len();
674    let mut swing_os = 0i8;
675    let mut swing_high = SwingState::na();
676    let mut swing_low = SwingState::na();
677    let mut bullish_breach = SwingState::na();
678    let mut bearish_breach = SwingState::na();
679    let mut bullish_breach_low_prev = f64::NAN;
680    let mut bullish_breach_high_prev = f64::NAN;
681    let mut bullish_breach_index_prev = 0usize;
682    let mut bearish_breach_low_prev = f64::NAN;
683    let mut bearish_breach_high_prev = f64::NAN;
684    let mut bearish_breach_index_prev = 0usize;
685    let mut bullish_blocks: Vec<BlockState> = Vec::with_capacity(2);
686    let mut bearish_blocks: Vec<BlockState> = Vec::with_capacity(2);
687    let mut max_high_window: VecDeque<usize> = VecDeque::with_capacity(swing_length + 1);
688    let mut min_low_window: VecDeque<usize> = VecDeque::with_capacity(swing_length + 1);
689
690    for i in 0..len {
691        if !valid_bar(open[i], high[i], low[i], close[i]) {
692            out_bullish_high[i] = f64::NAN;
693            out_bullish_low[i] = f64::NAN;
694            out_bullish_kind[i] = f64::NAN;
695            out_bullish_active[i] = f64::NAN;
696            out_bullish_mitigated[i] = f64::NAN;
697            out_bullish_new[i] = f64::NAN;
698            out_bearish_high[i] = f64::NAN;
699            out_bearish_low[i] = f64::NAN;
700            out_bearish_kind[i] = f64::NAN;
701            out_bearish_active[i] = f64::NAN;
702            out_bearish_mitigated[i] = f64::NAN;
703            out_bearish_new[i] = f64::NAN;
704            swing_os = 0;
705            swing_high = SwingState::na();
706            swing_low = SwingState::na();
707            bullish_breach = SwingState::na();
708            bearish_breach = SwingState::na();
709            bullish_breach_low_prev = f64::NAN;
710            bullish_breach_high_prev = f64::NAN;
711            bearish_breach_low_prev = f64::NAN;
712            bearish_breach_high_prev = f64::NAN;
713            bullish_blocks.clear();
714            bearish_blocks.clear();
715            reset_deque(&mut max_high_window);
716            reset_deque(&mut min_low_window);
717            continue;
718        }
719
720        while let Some(&idx) = max_high_window.back() {
721            if high[idx] <= high[i] {
722                max_high_window.pop_back();
723            } else {
724                break;
725            }
726        }
727        max_high_window.push_back(i);
728
729        while let Some(&idx) = min_low_window.back() {
730            if low[idx] >= low[i] {
731                min_low_window.pop_back();
732            } else {
733                break;
734            }
735        }
736        min_low_window.push_back(i);
737
738        let window_start = i.saturating_sub(swing_length.saturating_sub(1));
739        while let Some(&idx) = max_high_window.front() {
740            if idx < window_start {
741                max_high_window.pop_front();
742            } else {
743                break;
744            }
745        }
746        while let Some(&idx) = min_low_window.front() {
747            if idx < window_start {
748                min_low_window.pop_front();
749            } else {
750                break;
751            }
752        }
753
754        if i >= swing_length {
755            let candidate = i - swing_length;
756            let upper = high[*max_high_window.front().unwrap()];
757            let lower = low[*min_low_window.front().unwrap()];
758            let mut next_os = swing_os;
759            if high[candidate] > upper {
760                next_os = 0;
761            } else if low[candidate] < lower {
762                next_os = 1;
763            }
764
765            if next_os == 0 && swing_os != 0 {
766                swing_high = SwingState {
767                    value: high[candidate],
768                    index: candidate,
769                    cross: false,
770                };
771            }
772            if next_os == 1 && swing_os != 1 {
773                swing_low = SwingState {
774                    value: low[candidate],
775                    index: candidate,
776                    cross: false,
777                };
778            }
779            swing_os = next_os;
780        }
781
782        let mut breach_low = low[i];
783        let mut breach_high = high[i];
784        let mut breach_index = i;
785        if let Some(current) = bullish_blocks.first() {
786            let condition = low[i] <= current.high
787                && low[i] > current.low
788                && i > current.confirmed_index
789                && !current.is_mitigated
790                && current.is_active
791                && !current.is_propulsion
792                && open[i] > current.high;
793            if condition {
794                let prev_low = if bullish_breach_low_prev.is_finite() {
795                    bullish_breach_low_prev
796                } else {
797                    low[i]
798                };
799                breach_low = low[i].min(prev_low);
800                if breach_low == low[i] || !bullish_breach_high_prev.is_finite() {
801                    breach_high = high[i];
802                    breach_index = i;
803                } else {
804                    breach_high = bullish_breach_high_prev;
805                    breach_index = bullish_breach_index_prev;
806                }
807                bullish_breach = SwingState {
808                    value: breach_high,
809                    index: breach_index,
810                    cross: false,
811                };
812            }
813        }
814        bullish_breach_low_prev = breach_low;
815        bullish_breach_high_prev = breach_high;
816        bullish_breach_index_prev = breach_index;
817
818        let mut bear_breach_low = low[i];
819        let mut bear_breach_high = high[i];
820        let mut bear_breach_index = i;
821        if let Some(current) = bearish_blocks.first() {
822            let condition = high[i] >= current.low
823                && high[i] < current.high
824                && i > current.confirmed_index
825                && !current.is_mitigated
826                && current.is_active
827                && !current.is_propulsion
828                && open[i] < current.low;
829            if condition {
830                let prev_high = if bearish_breach_high_prev.is_finite() {
831                    bearish_breach_high_prev
832                } else {
833                    high[i]
834                };
835                bear_breach_high = high[i].max(prev_high);
836                if bear_breach_high == high[i] || !bearish_breach_low_prev.is_finite() {
837                    bear_breach_low = low[i];
838                    bear_breach_index = i;
839                } else {
840                    bear_breach_low = bearish_breach_low_prev;
841                    bear_breach_index = bearish_breach_index_prev;
842                }
843                bearish_breach = SwingState {
844                    value: bear_breach_low,
845                    index: bear_breach_index,
846                    cross: false,
847                };
848            }
849        }
850        bearish_breach_low_prev = bear_breach_low;
851        bearish_breach_high_prev = bear_breach_high;
852        bearish_breach_index_prev = bear_breach_index;
853
854        let mut bullish_new = 0.0;
855        let mut bearish_new = 0.0;
856
857        if swing_high.is_valid()
858            && !swing_high.cross
859            && close[i] > swing_high.value
860            && i > swing_high.index
861        {
862            swing_high.cross = true;
863            let seed = select_bullish_seed(i, swing_high.index, open, high, low, close);
864            if maybe_insert_bullish_order_block(&mut bullish_blocks, seed, i) {
865                bullish_new = 1.0;
866            }
867        }
868
869        if let Some(recent) = bullish_blocks.first() {
870            let create_pb = bullish_breach.is_valid()
871                && close[i] > bullish_breach.value
872                && !bullish_breach.cross
873                && !recent.is_mitigated
874                && bullish_breach.index > recent.confirmed_index;
875            if create_pb {
876                bullish_breach.cross = true;
877                if insert_bullish_propulsion(
878                    &mut bullish_blocks,
879                    bullish_breach.index,
880                    bullish_breach.value,
881                    i,
882                    open,
883                    low,
884                    close,
885                ) {
886                    bullish_new = 1.0;
887                }
888            }
889        }
890
891        for block in &mut bullish_blocks {
892            if block.is_active && !block.is_mitigated {
893                let mitigated = match mitigation_price {
894                    IctPropulsionBlockMitigationPrice::Close => close[i] < block.low,
895                    IctPropulsionBlockMitigationPrice::Wick => low[i] < block.low,
896                };
897                if mitigated {
898                    block.is_mitigated = true;
899                }
900                block.end_index = i;
901            }
902        }
903
904        if swing_low.is_valid()
905            && !swing_low.cross
906            && close[i] < swing_low.value
907            && i > swing_low.index
908        {
909            swing_low.cross = true;
910            let seed = select_bearish_seed(i, swing_low.index, open, high, low, close);
911            if maybe_insert_bearish_order_block(&mut bearish_blocks, seed, i) {
912                bearish_new = 1.0;
913            }
914        }
915
916        if let Some(recent) = bearish_blocks.first() {
917            let create_pb = bearish_breach.is_valid()
918                && close[i] < bearish_breach.value
919                && !bearish_breach.cross
920                && !recent.is_mitigated
921                && bearish_breach.index > recent.confirmed_index;
922            if create_pb {
923                bearish_breach.cross = true;
924                if insert_bearish_propulsion(
925                    &mut bearish_blocks,
926                    bearish_breach.index,
927                    bearish_breach.value,
928                    i,
929                    open,
930                    high,
931                    close,
932                ) {
933                    bearish_new = 1.0;
934                }
935            }
936        }
937
938        for block in &mut bearish_blocks {
939            if block.is_active && !block.is_mitigated {
940                let mitigated = match mitigation_price {
941                    IctPropulsionBlockMitigationPrice::Close => close[i] > block.high,
942                    IctPropulsionBlockMitigationPrice::Wick => high[i] > block.high,
943                };
944                if mitigated {
945                    block.is_mitigated = true;
946                }
947                block.end_index = i;
948            }
949        }
950
951        write_snapshot(
952            bullish_blocks.first(),
953            bullish_new,
954            out_bullish_high,
955            out_bullish_low,
956            out_bullish_kind,
957            out_bullish_active,
958            out_bullish_mitigated,
959            out_bullish_new,
960            i,
961        );
962        write_snapshot(
963            bearish_blocks.first(),
964            bearish_new,
965            out_bearish_high,
966            out_bearish_low,
967            out_bearish_kind,
968            out_bearish_active,
969            out_bearish_mitigated,
970            out_bearish_new,
971            i,
972        );
973    }
974}
975
976#[inline]
977pub fn ict_propulsion_block(
978    input: &IctPropulsionBlockInput,
979) -> Result<IctPropulsionBlockOutput, IctPropulsionBlockError> {
980    ict_propulsion_block_with_kernel(input, Kernel::Auto)
981}
982
983#[inline]
984pub fn ict_propulsion_block_with_kernel(
985    input: &IctPropulsionBlockInput,
986    kernel: Kernel,
987) -> Result<IctPropulsionBlockOutput, IctPropulsionBlockError> {
988    let (open, high, low, close) = input.as_refs();
989    validate_lengths(open, high, low, close)?;
990    let swing_length = input.get_swing_length();
991    let mitigation_price = input.get_mitigation_price();
992    validate_params(swing_length, mitigation_price)?;
993    let first_valid =
994        first_valid_bar(open, high, low, close).ok_or(IctPropulsionBlockError::AllValuesNaN)?;
995    let _kernel = normalize_kernel(kernel);
996    let len = close.len();
997
998    let mut bullish_high = alloc_with_nan_prefix(len, first_valid);
999    let mut bullish_low = alloc_with_nan_prefix(len, first_valid);
1000    let mut bullish_kind = alloc_with_nan_prefix(len, first_valid);
1001    let mut bullish_active = alloc_with_nan_prefix(len, first_valid);
1002    let mut bullish_mitigated = alloc_with_nan_prefix(len, first_valid);
1003    let mut bullish_new = alloc_with_nan_prefix(len, first_valid);
1004    let mut bearish_high = alloc_with_nan_prefix(len, first_valid);
1005    let mut bearish_low = alloc_with_nan_prefix(len, first_valid);
1006    let mut bearish_kind = alloc_with_nan_prefix(len, first_valid);
1007    let mut bearish_active = alloc_with_nan_prefix(len, first_valid);
1008    let mut bearish_mitigated = alloc_with_nan_prefix(len, first_valid);
1009    let mut bearish_new = alloc_with_nan_prefix(len, first_valid);
1010
1011    ict_propulsion_block_row_scalar(
1012        open,
1013        high,
1014        low,
1015        close,
1016        swing_length,
1017        mitigation_price,
1018        &mut bullish_high,
1019        &mut bullish_low,
1020        &mut bullish_kind,
1021        &mut bullish_active,
1022        &mut bullish_mitigated,
1023        &mut bullish_new,
1024        &mut bearish_high,
1025        &mut bearish_low,
1026        &mut bearish_kind,
1027        &mut bearish_active,
1028        &mut bearish_mitigated,
1029        &mut bearish_new,
1030    );
1031
1032    Ok(IctPropulsionBlockOutput {
1033        bullish_high,
1034        bullish_low,
1035        bullish_kind,
1036        bullish_active,
1037        bullish_mitigated,
1038        bullish_new,
1039        bearish_high,
1040        bearish_low,
1041        bearish_kind,
1042        bearish_active,
1043        bearish_mitigated,
1044        bearish_new,
1045    })
1046}
1047
1048#[inline]
1049pub fn ict_propulsion_block_into_slice(
1050    out_bullish_high: &mut [f64],
1051    out_bullish_low: &mut [f64],
1052    out_bullish_kind: &mut [f64],
1053    out_bullish_active: &mut [f64],
1054    out_bullish_mitigated: &mut [f64],
1055    out_bullish_new: &mut [f64],
1056    out_bearish_high: &mut [f64],
1057    out_bearish_low: &mut [f64],
1058    out_bearish_kind: &mut [f64],
1059    out_bearish_active: &mut [f64],
1060    out_bearish_mitigated: &mut [f64],
1061    out_bearish_new: &mut [f64],
1062    input: &IctPropulsionBlockInput,
1063    kernel: Kernel,
1064) -> Result<(), IctPropulsionBlockError> {
1065    let (open, high, low, close) = input.as_refs();
1066    validate_lengths(open, high, low, close)?;
1067    let len = close.len();
1068    if out_bullish_high.len() != len
1069        || out_bullish_low.len() != len
1070        || out_bullish_kind.len() != len
1071        || out_bullish_active.len() != len
1072        || out_bullish_mitigated.len() != len
1073        || out_bullish_new.len() != len
1074        || out_bearish_high.len() != len
1075        || out_bearish_low.len() != len
1076        || out_bearish_kind.len() != len
1077        || out_bearish_active.len() != len
1078        || out_bearish_mitigated.len() != len
1079        || out_bearish_new.len() != len
1080    {
1081        return Err(IctPropulsionBlockError::OutputLengthMismatch {
1082            expected: len,
1083            got: out_bullish_high
1084                .len()
1085                .max(out_bullish_low.len())
1086                .max(out_bullish_kind.len())
1087                .max(out_bullish_active.len())
1088                .max(out_bullish_mitigated.len())
1089                .max(out_bullish_new.len())
1090                .max(out_bearish_high.len())
1091                .max(out_bearish_low.len())
1092                .max(out_bearish_kind.len())
1093                .max(out_bearish_active.len())
1094                .max(out_bearish_mitigated.len())
1095                .max(out_bearish_new.len()),
1096        });
1097    }
1098
1099    let swing_length = input.get_swing_length();
1100    let mitigation_price = input.get_mitigation_price();
1101    validate_params(swing_length, mitigation_price)?;
1102    let _kernel = normalize_kernel(kernel);
1103
1104    ict_propulsion_block_row_scalar(
1105        open,
1106        high,
1107        low,
1108        close,
1109        swing_length,
1110        mitigation_price,
1111        out_bullish_high,
1112        out_bullish_low,
1113        out_bullish_kind,
1114        out_bullish_active,
1115        out_bullish_mitigated,
1116        out_bullish_new,
1117        out_bearish_high,
1118        out_bearish_low,
1119        out_bearish_kind,
1120        out_bearish_active,
1121        out_bearish_mitigated,
1122        out_bearish_new,
1123    );
1124    Ok(())
1125}
1126
1127#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
1128#[inline]
1129pub fn ict_propulsion_block_into(
1130    input: &IctPropulsionBlockInput,
1131    out_bullish_high: &mut [f64],
1132    out_bullish_low: &mut [f64],
1133    out_bullish_kind: &mut [f64],
1134    out_bullish_active: &mut [f64],
1135    out_bullish_mitigated: &mut [f64],
1136    out_bullish_new: &mut [f64],
1137    out_bearish_high: &mut [f64],
1138    out_bearish_low: &mut [f64],
1139    out_bearish_kind: &mut [f64],
1140    out_bearish_active: &mut [f64],
1141    out_bearish_mitigated: &mut [f64],
1142    out_bearish_new: &mut [f64],
1143) -> Result<(), IctPropulsionBlockError> {
1144    ict_propulsion_block_into_slice(
1145        out_bullish_high,
1146        out_bullish_low,
1147        out_bullish_kind,
1148        out_bullish_active,
1149        out_bullish_mitigated,
1150        out_bullish_new,
1151        out_bearish_high,
1152        out_bearish_low,
1153        out_bearish_kind,
1154        out_bearish_active,
1155        out_bearish_mitigated,
1156        out_bearish_new,
1157        input,
1158        Kernel::Auto,
1159    )
1160}
1161
1162#[derive(Clone, Debug)]
1163pub struct IctPropulsionBlockStream {
1164    swing_length: usize,
1165    mitigation_price: IctPropulsionBlockMitigationPrice,
1166    open: Vec<f64>,
1167    high: Vec<f64>,
1168    low: Vec<f64>,
1169    close: Vec<f64>,
1170}
1171
1172impl IctPropulsionBlockStream {
1173    #[inline]
1174    pub fn try_new(params: IctPropulsionBlockParams) -> Result<Self, IctPropulsionBlockError> {
1175        let swing_length = params.swing_length.unwrap_or(DEFAULT_SWING_LENGTH);
1176        let mitigation_price = params
1177            .mitigation_price
1178            .unwrap_or(IctPropulsionBlockMitigationPrice::Close);
1179        validate_params(swing_length, mitigation_price)?;
1180        Ok(Self {
1181            swing_length,
1182            mitigation_price,
1183            open: Vec::new(),
1184            high: Vec::new(),
1185            low: Vec::new(),
1186            close: Vec::new(),
1187        })
1188    }
1189
1190    #[inline]
1191    pub fn update(
1192        &mut self,
1193        open: f64,
1194        high: f64,
1195        low: f64,
1196        close: f64,
1197    ) -> Option<(f64, f64, f64, f64, f64, f64, f64, f64, f64, f64, f64, f64)> {
1198        if !valid_bar(open, high, low, close) {
1199            self.open.clear();
1200            self.high.clear();
1201            self.low.clear();
1202            self.close.clear();
1203            return None;
1204        }
1205
1206        self.open.push(open);
1207        self.high.push(high);
1208        self.low.push(low);
1209        self.close.push(close);
1210
1211        let input = IctPropulsionBlockInput::from_slices(
1212            &self.open,
1213            &self.high,
1214            &self.low,
1215            &self.close,
1216            IctPropulsionBlockParams {
1217                swing_length: Some(self.swing_length),
1218                mitigation_price: Some(self.mitigation_price),
1219            },
1220        );
1221        let out = ict_propulsion_block_with_kernel(&input, Kernel::Scalar).ok()?;
1222        let last = self.close.len() - 1;
1223        Some((
1224            out.bullish_high[last],
1225            out.bullish_low[last],
1226            out.bullish_kind[last],
1227            out.bullish_active[last],
1228            out.bullish_mitigated[last],
1229            out.bullish_new[last],
1230            out.bearish_high[last],
1231            out.bearish_low[last],
1232            out.bearish_kind[last],
1233            out.bearish_active[last],
1234            out.bearish_mitigated[last],
1235            out.bearish_new[last],
1236        ))
1237    }
1238}
1239
1240#[derive(Clone, Debug)]
1241pub struct IctPropulsionBlockBatchRange {
1242    pub swing_length: (usize, usize, usize),
1243    pub mitigation_price: (bool, bool),
1244}
1245
1246impl Default for IctPropulsionBlockBatchRange {
1247    fn default() -> Self {
1248        Self {
1249            swing_length: (DEFAULT_SWING_LENGTH, DEFAULT_SWING_LENGTH, 0),
1250            mitigation_price: (true, false),
1251        }
1252    }
1253}
1254
1255#[derive(Clone, Debug)]
1256pub struct IctPropulsionBlockBatchOutput {
1257    pub bullish_high: Vec<f64>,
1258    pub bullish_low: Vec<f64>,
1259    pub bullish_kind: Vec<f64>,
1260    pub bullish_active: Vec<f64>,
1261    pub bullish_mitigated: Vec<f64>,
1262    pub bullish_new: Vec<f64>,
1263    pub bearish_high: Vec<f64>,
1264    pub bearish_low: Vec<f64>,
1265    pub bearish_kind: Vec<f64>,
1266    pub bearish_active: Vec<f64>,
1267    pub bearish_mitigated: Vec<f64>,
1268    pub bearish_new: Vec<f64>,
1269    pub combos: Vec<IctPropulsionBlockParams>,
1270    pub rows: usize,
1271    pub cols: usize,
1272}
1273
1274#[derive(Clone, Debug)]
1275pub struct IctPropulsionBlockBatchBuilder {
1276    range: IctPropulsionBlockBatchRange,
1277    kernel: Kernel,
1278}
1279
1280impl Default for IctPropulsionBlockBatchBuilder {
1281    fn default() -> Self {
1282        Self {
1283            range: IctPropulsionBlockBatchRange::default(),
1284            kernel: Kernel::Auto,
1285        }
1286    }
1287}
1288
1289impl IctPropulsionBlockBatchBuilder {
1290    #[inline]
1291    pub fn new() -> Self {
1292        Self::default()
1293    }
1294
1295    #[inline]
1296    pub fn swing_length_range(mut self, range: (usize, usize, usize)) -> Self {
1297        self.range.swing_length = range;
1298        self
1299    }
1300
1301    #[inline]
1302    pub fn mitigation_price_toggle(mut self, include_close: bool, include_wick: bool) -> Self {
1303        self.range.mitigation_price = (include_close, include_wick);
1304        self
1305    }
1306
1307    #[inline]
1308    pub fn kernel(mut self, kernel: Kernel) -> Self {
1309        self.kernel = kernel;
1310        self
1311    }
1312
1313    #[inline]
1314    pub fn apply_slices(
1315        self,
1316        open: &[f64],
1317        high: &[f64],
1318        low: &[f64],
1319        close: &[f64],
1320    ) -> Result<IctPropulsionBlockBatchOutput, IctPropulsionBlockError> {
1321        ict_propulsion_block_batch_with_kernel(open, high, low, close, &self.range, self.kernel)
1322    }
1323
1324    #[inline]
1325    pub fn apply(
1326        self,
1327        candles: &Candles,
1328    ) -> Result<IctPropulsionBlockBatchOutput, IctPropulsionBlockError> {
1329        ict_propulsion_block_batch_with_kernel(
1330            &candles.open,
1331            &candles.high,
1332            &candles.low,
1333            &candles.close,
1334            &self.range,
1335            self.kernel,
1336        )
1337    }
1338}
1339
1340#[inline]
1341fn expand_axis_usize(
1342    (start, end, step): (usize, usize, usize),
1343) -> Result<Vec<usize>, IctPropulsionBlockError> {
1344    if step == 0 || start == end {
1345        return Ok(vec![start]);
1346    }
1347    let mut out = Vec::new();
1348    if start <= end {
1349        let mut value = start;
1350        while value <= end {
1351            out.push(value);
1352            match value.checked_add(step) {
1353                Some(next) => value = next,
1354                None => break,
1355            }
1356        }
1357    } else {
1358        let mut value = start;
1359        loop {
1360            if value < end {
1361                break;
1362            }
1363            out.push(value);
1364            match value.checked_sub(step) {
1365                Some(next) => value = next,
1366                None => break,
1367            }
1368        }
1369    }
1370    if out.is_empty() {
1371        return Err(IctPropulsionBlockError::InvalidRange {
1372            start: start.to_string(),
1373            end: end.to_string(),
1374            step: step.to_string(),
1375        });
1376    }
1377    Ok(out)
1378}
1379
1380#[inline]
1381pub fn expand_grid_ict_propulsion_block(
1382    range: &IctPropulsionBlockBatchRange,
1383) -> Result<Vec<IctPropulsionBlockParams>, IctPropulsionBlockError> {
1384    let swing_lengths = expand_axis_usize(range.swing_length)?;
1385    let mut mitigation_prices = Vec::new();
1386    if range.mitigation_price.0 {
1387        mitigation_prices.push(IctPropulsionBlockMitigationPrice::Close);
1388    }
1389    if range.mitigation_price.1 {
1390        mitigation_prices.push(IctPropulsionBlockMitigationPrice::Wick);
1391    }
1392    if mitigation_prices.is_empty() {
1393        mitigation_prices.push(IctPropulsionBlockMitigationPrice::Close);
1394    }
1395
1396    let mut out = Vec::with_capacity(swing_lengths.len().saturating_mul(mitigation_prices.len()));
1397    for &swing_length in &swing_lengths {
1398        for &mitigation_price in &mitigation_prices {
1399            out.push(IctPropulsionBlockParams {
1400                swing_length: Some(swing_length),
1401                mitigation_price: Some(mitigation_price),
1402            });
1403        }
1404    }
1405    Ok(out)
1406}
1407
1408#[inline]
1409pub fn ict_propulsion_block_batch_with_kernel(
1410    open: &[f64],
1411    high: &[f64],
1412    low: &[f64],
1413    close: &[f64],
1414    sweep: &IctPropulsionBlockBatchRange,
1415    kernel: Kernel,
1416) -> Result<IctPropulsionBlockBatchOutput, IctPropulsionBlockError> {
1417    let batch_kernel = match kernel {
1418        Kernel::Auto => detect_best_batch_kernel(),
1419        other if other.is_batch() => other,
1420        other => return Err(IctPropulsionBlockError::InvalidKernelForBatch(other)),
1421    };
1422    ict_propulsion_block_batch_par_slice(open, high, low, close, sweep, batch_kernel.to_non_batch())
1423}
1424
1425#[inline]
1426pub fn ict_propulsion_block_batch_slice(
1427    open: &[f64],
1428    high: &[f64],
1429    low: &[f64],
1430    close: &[f64],
1431    sweep: &IctPropulsionBlockBatchRange,
1432    kernel: Kernel,
1433) -> Result<IctPropulsionBlockBatchOutput, IctPropulsionBlockError> {
1434    ict_propulsion_block_batch_inner(open, high, low, close, sweep, kernel)
1435}
1436
1437#[inline]
1438pub fn ict_propulsion_block_batch_par_slice(
1439    open: &[f64],
1440    high: &[f64],
1441    low: &[f64],
1442    close: &[f64],
1443    sweep: &IctPropulsionBlockBatchRange,
1444    kernel: Kernel,
1445) -> Result<IctPropulsionBlockBatchOutput, IctPropulsionBlockError> {
1446    ict_propulsion_block_batch_inner(open, high, low, close, sweep, kernel)
1447}
1448
1449fn ict_propulsion_block_batch_inner(
1450    open: &[f64],
1451    high: &[f64],
1452    low: &[f64],
1453    close: &[f64],
1454    sweep: &IctPropulsionBlockBatchRange,
1455    _kernel: Kernel,
1456) -> Result<IctPropulsionBlockBatchOutput, IctPropulsionBlockError> {
1457    validate_lengths(open, high, low, close)?;
1458    let combos = expand_grid_ict_propulsion_block(sweep)?;
1459    for params in &combos {
1460        validate_params(
1461            params.swing_length.unwrap_or(DEFAULT_SWING_LENGTH),
1462            params
1463                .mitigation_price
1464                .unwrap_or(IctPropulsionBlockMitigationPrice::Close),
1465        )?;
1466    }
1467
1468    let _first_valid =
1469        first_valid_bar(open, high, low, close).ok_or(IctPropulsionBlockError::AllValuesNaN)?;
1470    let rows = combos.len();
1471    let cols = close.len();
1472    let total = rows
1473        .checked_mul(cols)
1474        .ok_or(IctPropulsionBlockError::OutputLengthMismatch {
1475            expected: usize::MAX,
1476            got: 0,
1477        })?;
1478
1479    let bullish_high_matrix = make_uninit_matrix(rows, cols);
1480    let bullish_low_matrix = make_uninit_matrix(rows, cols);
1481    let bullish_kind_matrix = make_uninit_matrix(rows, cols);
1482    let bullish_active_matrix = make_uninit_matrix(rows, cols);
1483    let bullish_mitigated_matrix = make_uninit_matrix(rows, cols);
1484    let bullish_new_matrix = make_uninit_matrix(rows, cols);
1485    let bearish_high_matrix = make_uninit_matrix(rows, cols);
1486    let bearish_low_matrix = make_uninit_matrix(rows, cols);
1487    let bearish_kind_matrix = make_uninit_matrix(rows, cols);
1488    let bearish_active_matrix = make_uninit_matrix(rows, cols);
1489    let bearish_mitigated_matrix = make_uninit_matrix(rows, cols);
1490    let bearish_new_matrix = make_uninit_matrix(rows, cols);
1491
1492    let mut bullish_high_guard = ManuallyDrop::new(bullish_high_matrix);
1493    let mut bullish_low_guard = ManuallyDrop::new(bullish_low_matrix);
1494    let mut bullish_kind_guard = ManuallyDrop::new(bullish_kind_matrix);
1495    let mut bullish_active_guard = ManuallyDrop::new(bullish_active_matrix);
1496    let mut bullish_mitigated_guard = ManuallyDrop::new(bullish_mitigated_matrix);
1497    let mut bullish_new_guard = ManuallyDrop::new(bullish_new_matrix);
1498    let mut bearish_high_guard = ManuallyDrop::new(bearish_high_matrix);
1499    let mut bearish_low_guard = ManuallyDrop::new(bearish_low_matrix);
1500    let mut bearish_kind_guard = ManuallyDrop::new(bearish_kind_matrix);
1501    let mut bearish_active_guard = ManuallyDrop::new(bearish_active_matrix);
1502    let mut bearish_mitigated_guard = ManuallyDrop::new(bearish_mitigated_matrix);
1503    let mut bearish_new_guard = ManuallyDrop::new(bearish_new_matrix);
1504
1505    let bullish_high_mu: &mut [MaybeUninit<f64>] = unsafe {
1506        std::slice::from_raw_parts_mut(bullish_high_guard.as_mut_ptr(), bullish_high_guard.len())
1507    };
1508    let bullish_low_mu: &mut [MaybeUninit<f64>] = unsafe {
1509        std::slice::from_raw_parts_mut(bullish_low_guard.as_mut_ptr(), bullish_low_guard.len())
1510    };
1511    let bullish_kind_mu: &mut [MaybeUninit<f64>] = unsafe {
1512        std::slice::from_raw_parts_mut(bullish_kind_guard.as_mut_ptr(), bullish_kind_guard.len())
1513    };
1514    let bullish_active_mu: &mut [MaybeUninit<f64>] = unsafe {
1515        std::slice::from_raw_parts_mut(
1516            bullish_active_guard.as_mut_ptr(),
1517            bullish_active_guard.len(),
1518        )
1519    };
1520    let bullish_mitigated_mu: &mut [MaybeUninit<f64>] = unsafe {
1521        std::slice::from_raw_parts_mut(
1522            bullish_mitigated_guard.as_mut_ptr(),
1523            bullish_mitigated_guard.len(),
1524        )
1525    };
1526    let bullish_new_mu: &mut [MaybeUninit<f64>] = unsafe {
1527        std::slice::from_raw_parts_mut(bullish_new_guard.as_mut_ptr(), bullish_new_guard.len())
1528    };
1529    let bearish_high_mu: &mut [MaybeUninit<f64>] = unsafe {
1530        std::slice::from_raw_parts_mut(bearish_high_guard.as_mut_ptr(), bearish_high_guard.len())
1531    };
1532    let bearish_low_mu: &mut [MaybeUninit<f64>] = unsafe {
1533        std::slice::from_raw_parts_mut(bearish_low_guard.as_mut_ptr(), bearish_low_guard.len())
1534    };
1535    let bearish_kind_mu: &mut [MaybeUninit<f64>] = unsafe {
1536        std::slice::from_raw_parts_mut(bearish_kind_guard.as_mut_ptr(), bearish_kind_guard.len())
1537    };
1538    let bearish_active_mu: &mut [MaybeUninit<f64>] = unsafe {
1539        std::slice::from_raw_parts_mut(
1540            bearish_active_guard.as_mut_ptr(),
1541            bearish_active_guard.len(),
1542        )
1543    };
1544    let bearish_mitigated_mu: &mut [MaybeUninit<f64>] = unsafe {
1545        std::slice::from_raw_parts_mut(
1546            bearish_mitigated_guard.as_mut_ptr(),
1547            bearish_mitigated_guard.len(),
1548        )
1549    };
1550    let bearish_new_mu: &mut [MaybeUninit<f64>] = unsafe {
1551        std::slice::from_raw_parts_mut(bearish_new_guard.as_mut_ptr(), bearish_new_guard.len())
1552    };
1553
1554    for row in 0..rows {
1555        let base = row * cols;
1556        let out_bullish_high = unsafe {
1557            std::slice::from_raw_parts_mut(
1558                bullish_high_mu[base..base + cols].as_mut_ptr() as *mut f64,
1559                cols,
1560            )
1561        };
1562        let out_bullish_low = unsafe {
1563            std::slice::from_raw_parts_mut(
1564                bullish_low_mu[base..base + cols].as_mut_ptr() as *mut f64,
1565                cols,
1566            )
1567        };
1568        let out_bullish_kind = unsafe {
1569            std::slice::from_raw_parts_mut(
1570                bullish_kind_mu[base..base + cols].as_mut_ptr() as *mut f64,
1571                cols,
1572            )
1573        };
1574        let out_bullish_active = unsafe {
1575            std::slice::from_raw_parts_mut(
1576                bullish_active_mu[base..base + cols].as_mut_ptr() as *mut f64,
1577                cols,
1578            )
1579        };
1580        let out_bullish_mitigated = unsafe {
1581            std::slice::from_raw_parts_mut(
1582                bullish_mitigated_mu[base..base + cols].as_mut_ptr() as *mut f64,
1583                cols,
1584            )
1585        };
1586        let out_bullish_new = unsafe {
1587            std::slice::from_raw_parts_mut(
1588                bullish_new_mu[base..base + cols].as_mut_ptr() as *mut f64,
1589                cols,
1590            )
1591        };
1592        let out_bearish_high = unsafe {
1593            std::slice::from_raw_parts_mut(
1594                bearish_high_mu[base..base + cols].as_mut_ptr() as *mut f64,
1595                cols,
1596            )
1597        };
1598        let out_bearish_low = unsafe {
1599            std::slice::from_raw_parts_mut(
1600                bearish_low_mu[base..base + cols].as_mut_ptr() as *mut f64,
1601                cols,
1602            )
1603        };
1604        let out_bearish_kind = unsafe {
1605            std::slice::from_raw_parts_mut(
1606                bearish_kind_mu[base..base + cols].as_mut_ptr() as *mut f64,
1607                cols,
1608            )
1609        };
1610        let out_bearish_active = unsafe {
1611            std::slice::from_raw_parts_mut(
1612                bearish_active_mu[base..base + cols].as_mut_ptr() as *mut f64,
1613                cols,
1614            )
1615        };
1616        let out_bearish_mitigated = unsafe {
1617            std::slice::from_raw_parts_mut(
1618                bearish_mitigated_mu[base..base + cols].as_mut_ptr() as *mut f64,
1619                cols,
1620            )
1621        };
1622        let out_bearish_new = unsafe {
1623            std::slice::from_raw_parts_mut(
1624                bearish_new_mu[base..base + cols].as_mut_ptr() as *mut f64,
1625                cols,
1626            )
1627        };
1628
1629        ict_propulsion_block_row_scalar(
1630            open,
1631            high,
1632            low,
1633            close,
1634            combos[row].swing_length.unwrap_or(DEFAULT_SWING_LENGTH),
1635            combos[row]
1636                .mitigation_price
1637                .unwrap_or(IctPropulsionBlockMitigationPrice::Close),
1638            out_bullish_high,
1639            out_bullish_low,
1640            out_bullish_kind,
1641            out_bullish_active,
1642            out_bullish_mitigated,
1643            out_bullish_new,
1644            out_bearish_high,
1645            out_bearish_low,
1646            out_bearish_kind,
1647            out_bearish_active,
1648            out_bearish_mitigated,
1649            out_bearish_new,
1650        );
1651    }
1652
1653    let bullish_high = unsafe {
1654        Vec::from_raw_parts(
1655            bullish_high_guard.as_mut_ptr() as *mut f64,
1656            total,
1657            bullish_high_guard.capacity(),
1658        )
1659    };
1660    let bullish_low = unsafe {
1661        Vec::from_raw_parts(
1662            bullish_low_guard.as_mut_ptr() as *mut f64,
1663            total,
1664            bullish_low_guard.capacity(),
1665        )
1666    };
1667    let bullish_kind = unsafe {
1668        Vec::from_raw_parts(
1669            bullish_kind_guard.as_mut_ptr() as *mut f64,
1670            total,
1671            bullish_kind_guard.capacity(),
1672        )
1673    };
1674    let bullish_active = unsafe {
1675        Vec::from_raw_parts(
1676            bullish_active_guard.as_mut_ptr() as *mut f64,
1677            total,
1678            bullish_active_guard.capacity(),
1679        )
1680    };
1681    let bullish_mitigated = unsafe {
1682        Vec::from_raw_parts(
1683            bullish_mitigated_guard.as_mut_ptr() as *mut f64,
1684            total,
1685            bullish_mitigated_guard.capacity(),
1686        )
1687    };
1688    let bullish_new = unsafe {
1689        Vec::from_raw_parts(
1690            bullish_new_guard.as_mut_ptr() as *mut f64,
1691            total,
1692            bullish_new_guard.capacity(),
1693        )
1694    };
1695    let bearish_high = unsafe {
1696        Vec::from_raw_parts(
1697            bearish_high_guard.as_mut_ptr() as *mut f64,
1698            total,
1699            bearish_high_guard.capacity(),
1700        )
1701    };
1702    let bearish_low = unsafe {
1703        Vec::from_raw_parts(
1704            bearish_low_guard.as_mut_ptr() as *mut f64,
1705            total,
1706            bearish_low_guard.capacity(),
1707        )
1708    };
1709    let bearish_kind = unsafe {
1710        Vec::from_raw_parts(
1711            bearish_kind_guard.as_mut_ptr() as *mut f64,
1712            total,
1713            bearish_kind_guard.capacity(),
1714        )
1715    };
1716    let bearish_active = unsafe {
1717        Vec::from_raw_parts(
1718            bearish_active_guard.as_mut_ptr() as *mut f64,
1719            total,
1720            bearish_active_guard.capacity(),
1721        )
1722    };
1723    let bearish_mitigated = unsafe {
1724        Vec::from_raw_parts(
1725            bearish_mitigated_guard.as_mut_ptr() as *mut f64,
1726            total,
1727            bearish_mitigated_guard.capacity(),
1728        )
1729    };
1730    let bearish_new = unsafe {
1731        Vec::from_raw_parts(
1732            bearish_new_guard.as_mut_ptr() as *mut f64,
1733            total,
1734            bearish_new_guard.capacity(),
1735        )
1736    };
1737
1738    Ok(IctPropulsionBlockBatchOutput {
1739        bullish_high,
1740        bullish_low,
1741        bullish_kind,
1742        bullish_active,
1743        bullish_mitigated,
1744        bullish_new,
1745        bearish_high,
1746        bearish_low,
1747        bearish_kind,
1748        bearish_active,
1749        bearish_mitigated,
1750        bearish_new,
1751        combos,
1752        rows,
1753        cols,
1754    })
1755}
1756
1757#[allow(clippy::too_many_arguments)]
1758fn ict_propulsion_block_batch_inner_into(
1759    open: &[f64],
1760    high: &[f64],
1761    low: &[f64],
1762    close: &[f64],
1763    sweep: &IctPropulsionBlockBatchRange,
1764    kernel: Kernel,
1765    out_bullish_high: &mut [f64],
1766    out_bullish_low: &mut [f64],
1767    out_bullish_kind: &mut [f64],
1768    out_bullish_active: &mut [f64],
1769    out_bullish_mitigated: &mut [f64],
1770    out_bullish_new: &mut [f64],
1771    out_bearish_high: &mut [f64],
1772    out_bearish_low: &mut [f64],
1773    out_bearish_kind: &mut [f64],
1774    out_bearish_active: &mut [f64],
1775    out_bearish_mitigated: &mut [f64],
1776    out_bearish_new: &mut [f64],
1777) -> Result<Vec<IctPropulsionBlockParams>, IctPropulsionBlockError> {
1778    validate_lengths(open, high, low, close)?;
1779    let combos = expand_grid_ict_propulsion_block(sweep)?;
1780    for params in &combos {
1781        validate_params(
1782            params.swing_length.unwrap_or(DEFAULT_SWING_LENGTH),
1783            params
1784                .mitigation_price
1785                .unwrap_or(IctPropulsionBlockMitigationPrice::Close),
1786        )?;
1787    }
1788    let _first_valid =
1789        first_valid_bar(open, high, low, close).ok_or(IctPropulsionBlockError::AllValuesNaN)?;
1790    let rows = combos.len();
1791    let cols = close.len();
1792    let total = rows
1793        .checked_mul(cols)
1794        .ok_or(IctPropulsionBlockError::OutputLengthMismatch {
1795            expected: usize::MAX,
1796            got: 0,
1797        })?;
1798
1799    if out_bullish_high.len() != total
1800        || out_bullish_low.len() != total
1801        || out_bullish_kind.len() != total
1802        || out_bullish_active.len() != total
1803        || out_bullish_mitigated.len() != total
1804        || out_bullish_new.len() != total
1805        || out_bearish_high.len() != total
1806        || out_bearish_low.len() != total
1807        || out_bearish_kind.len() != total
1808        || out_bearish_active.len() != total
1809        || out_bearish_mitigated.len() != total
1810        || out_bearish_new.len() != total
1811    {
1812        return Err(IctPropulsionBlockError::OutputLengthMismatch {
1813            expected: total,
1814            got: out_bullish_high
1815                .len()
1816                .max(out_bullish_low.len())
1817                .max(out_bullish_kind.len())
1818                .max(out_bullish_active.len())
1819                .max(out_bullish_mitigated.len())
1820                .max(out_bullish_new.len())
1821                .max(out_bearish_high.len())
1822                .max(out_bearish_low.len())
1823                .max(out_bearish_kind.len())
1824                .max(out_bearish_active.len())
1825                .max(out_bearish_mitigated.len())
1826                .max(out_bearish_new.len()),
1827        });
1828    }
1829
1830    let _kernel = kernel;
1831    for row in 0..rows {
1832        let base = row * cols;
1833        ict_propulsion_block_row_scalar(
1834            open,
1835            high,
1836            low,
1837            close,
1838            combos[row].swing_length.unwrap_or(DEFAULT_SWING_LENGTH),
1839            combos[row]
1840                .mitigation_price
1841                .unwrap_or(IctPropulsionBlockMitigationPrice::Close),
1842            &mut out_bullish_high[base..base + cols],
1843            &mut out_bullish_low[base..base + cols],
1844            &mut out_bullish_kind[base..base + cols],
1845            &mut out_bullish_active[base..base + cols],
1846            &mut out_bullish_mitigated[base..base + cols],
1847            &mut out_bullish_new[base..base + cols],
1848            &mut out_bearish_high[base..base + cols],
1849            &mut out_bearish_low[base..base + cols],
1850            &mut out_bearish_kind[base..base + cols],
1851            &mut out_bearish_active[base..base + cols],
1852            &mut out_bearish_mitigated[base..base + cols],
1853            &mut out_bearish_new[base..base + cols],
1854        );
1855    }
1856    Ok(combos)
1857}
1858
1859fn parse_mitigation_price(
1860    value: &str,
1861) -> Result<IctPropulsionBlockMitigationPrice, IctPropulsionBlockError> {
1862    if value.eq_ignore_ascii_case("close") || value.eq_ignore_ascii_case("closing_price") {
1863        return Ok(IctPropulsionBlockMitigationPrice::Close);
1864    }
1865    if value.eq_ignore_ascii_case("wick") {
1866        return Ok(IctPropulsionBlockMitigationPrice::Wick);
1867    }
1868    Err(IctPropulsionBlockError::InvalidMitigationPrice {
1869        mitigation_price: value.to_string(),
1870    })
1871}
1872
1873#[cfg(feature = "python")]
1874#[pyfunction(name = "ict_propulsion_block")]
1875#[pyo3(signature = (open, high, low, close, swing_length=DEFAULT_SWING_LENGTH, mitigation_price="close", kernel=None))]
1876pub fn ict_propulsion_block_py<'py>(
1877    py: Python<'py>,
1878    open: PyReadonlyArray1<'py, f64>,
1879    high: PyReadonlyArray1<'py, f64>,
1880    low: PyReadonlyArray1<'py, f64>,
1881    close: PyReadonlyArray1<'py, f64>,
1882    swing_length: usize,
1883    mitigation_price: &str,
1884    kernel: Option<&str>,
1885) -> PyResult<(
1886    Bound<'py, PyArray1<f64>>,
1887    Bound<'py, PyArray1<f64>>,
1888    Bound<'py, PyArray1<f64>>,
1889    Bound<'py, PyArray1<f64>>,
1890    Bound<'py, PyArray1<f64>>,
1891    Bound<'py, PyArray1<f64>>,
1892    Bound<'py, PyArray1<f64>>,
1893    Bound<'py, PyArray1<f64>>,
1894    Bound<'py, PyArray1<f64>>,
1895    Bound<'py, PyArray1<f64>>,
1896    Bound<'py, PyArray1<f64>>,
1897    Bound<'py, PyArray1<f64>>,
1898)> {
1899    let open = open.as_slice()?;
1900    let high = high.as_slice()?;
1901    let low = low.as_slice()?;
1902    let close = close.as_slice()?;
1903    let input = IctPropulsionBlockInput::from_slices(
1904        open,
1905        high,
1906        low,
1907        close,
1908        IctPropulsionBlockParams {
1909            swing_length: Some(swing_length),
1910            mitigation_price: Some(
1911                parse_mitigation_price(mitigation_price)
1912                    .map_err(|e| PyValueError::new_err(e.to_string()))?,
1913            ),
1914        },
1915    );
1916    let kernel = validate_kernel(kernel, false)?;
1917    let out = py
1918        .allow_threads(|| ict_propulsion_block_with_kernel(&input, kernel))
1919        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1920    Ok((
1921        out.bullish_high.into_pyarray(py),
1922        out.bullish_low.into_pyarray(py),
1923        out.bullish_kind.into_pyarray(py),
1924        out.bullish_active.into_pyarray(py),
1925        out.bullish_mitigated.into_pyarray(py),
1926        out.bullish_new.into_pyarray(py),
1927        out.bearish_high.into_pyarray(py),
1928        out.bearish_low.into_pyarray(py),
1929        out.bearish_kind.into_pyarray(py),
1930        out.bearish_active.into_pyarray(py),
1931        out.bearish_mitigated.into_pyarray(py),
1932        out.bearish_new.into_pyarray(py),
1933    ))
1934}
1935
1936#[cfg(feature = "python")]
1937#[pyclass(name = "IctPropulsionBlockStream")]
1938pub struct IctPropulsionBlockStreamPy {
1939    stream: IctPropulsionBlockStream,
1940}
1941
1942#[cfg(feature = "python")]
1943#[pymethods]
1944impl IctPropulsionBlockStreamPy {
1945    #[new]
1946    #[pyo3(signature = (swing_length=DEFAULT_SWING_LENGTH, mitigation_price="close"))]
1947    fn new(swing_length: usize, mitigation_price: &str) -> PyResult<Self> {
1948        let stream = IctPropulsionBlockStream::try_new(IctPropulsionBlockParams {
1949            swing_length: Some(swing_length),
1950            mitigation_price: Some(
1951                parse_mitigation_price(mitigation_price)
1952                    .map_err(|e| PyValueError::new_err(e.to_string()))?,
1953            ),
1954        })
1955        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1956        Ok(Self { stream })
1957    }
1958
1959    fn update(
1960        &mut self,
1961        open: f64,
1962        high: f64,
1963        low: f64,
1964        close: f64,
1965    ) -> Option<(f64, f64, f64, f64, f64, f64, f64, f64, f64, f64, f64, f64)> {
1966        self.stream.update(open, high, low, close)
1967    }
1968}
1969
1970#[cfg(feature = "python")]
1971#[pyfunction(name = "ict_propulsion_block_batch")]
1972#[pyo3(signature = (open, high, low, close, swing_length_range, mitigation_price_toggle=(true, false), kernel=None))]
1973pub fn ict_propulsion_block_batch_py<'py>(
1974    py: Python<'py>,
1975    open: PyReadonlyArray1<'py, f64>,
1976    high: PyReadonlyArray1<'py, f64>,
1977    low: PyReadonlyArray1<'py, f64>,
1978    close: PyReadonlyArray1<'py, f64>,
1979    swing_length_range: (usize, usize, usize),
1980    mitigation_price_toggle: (bool, bool),
1981    kernel: Option<&str>,
1982) -> PyResult<Bound<'py, PyDict>> {
1983    let open = open.as_slice()?;
1984    let high = high.as_slice()?;
1985    let low = low.as_slice()?;
1986    let close = close.as_slice()?;
1987    let sweep = IctPropulsionBlockBatchRange {
1988        swing_length: swing_length_range,
1989        mitigation_price: mitigation_price_toggle,
1990    };
1991    let combos = expand_grid_ict_propulsion_block(&sweep)
1992        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1993    let rows = combos.len();
1994    let cols = close.len();
1995    let total = rows
1996        .checked_mul(cols)
1997        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
1998
1999    let bullish_high_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
2000    let bullish_low_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
2001    let bullish_kind_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
2002    let bullish_active_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
2003    let bullish_mitigated_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
2004    let bullish_new_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
2005    let bearish_high_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
2006    let bearish_low_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
2007    let bearish_kind_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
2008    let bearish_active_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
2009    let bearish_mitigated_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
2010    let bearish_new_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
2011
2012    let out_bullish_high = unsafe { bullish_high_arr.as_slice_mut()? };
2013    let out_bullish_low = unsafe { bullish_low_arr.as_slice_mut()? };
2014    let out_bullish_kind = unsafe { bullish_kind_arr.as_slice_mut()? };
2015    let out_bullish_active = unsafe { bullish_active_arr.as_slice_mut()? };
2016    let out_bullish_mitigated = unsafe { bullish_mitigated_arr.as_slice_mut()? };
2017    let out_bullish_new = unsafe { bullish_new_arr.as_slice_mut()? };
2018    let out_bearish_high = unsafe { bearish_high_arr.as_slice_mut()? };
2019    let out_bearish_low = unsafe { bearish_low_arr.as_slice_mut()? };
2020    let out_bearish_kind = unsafe { bearish_kind_arr.as_slice_mut()? };
2021    let out_bearish_active = unsafe { bearish_active_arr.as_slice_mut()? };
2022    let out_bearish_mitigated = unsafe { bearish_mitigated_arr.as_slice_mut()? };
2023    let out_bearish_new = unsafe { bearish_new_arr.as_slice_mut()? };
2024
2025    let kernel = validate_kernel(kernel, true)?;
2026    py.allow_threads(|| {
2027        let batch_kernel = match kernel {
2028            Kernel::Auto => detect_best_batch_kernel(),
2029            other => other,
2030        };
2031        ict_propulsion_block_batch_inner_into(
2032            open,
2033            high,
2034            low,
2035            close,
2036            &sweep,
2037            batch_kernel.to_non_batch(),
2038            out_bullish_high,
2039            out_bullish_low,
2040            out_bullish_kind,
2041            out_bullish_active,
2042            out_bullish_mitigated,
2043            out_bullish_new,
2044            out_bearish_high,
2045            out_bearish_low,
2046            out_bearish_kind,
2047            out_bearish_active,
2048            out_bearish_mitigated,
2049            out_bearish_new,
2050        )
2051    })
2052    .map_err(|e| PyValueError::new_err(e.to_string()))?;
2053
2054    let swing_lengths: Vec<u64> = combos
2055        .iter()
2056        .map(|params| params.swing_length.unwrap_or(DEFAULT_SWING_LENGTH) as u64)
2057        .collect();
2058    let mitigation_prices: Vec<&str> = combos
2059        .iter()
2060        .map(|params| {
2061            params
2062                .mitigation_price
2063                .unwrap_or(IctPropulsionBlockMitigationPrice::Close)
2064                .as_str()
2065        })
2066        .collect();
2067
2068    let dict = PyDict::new(py);
2069    dict.set_item("bullish_high", bullish_high_arr.reshape((rows, cols))?)?;
2070    dict.set_item("bullish_low", bullish_low_arr.reshape((rows, cols))?)?;
2071    dict.set_item("bullish_kind", bullish_kind_arr.reshape((rows, cols))?)?;
2072    dict.set_item("bullish_active", bullish_active_arr.reshape((rows, cols))?)?;
2073    dict.set_item(
2074        "bullish_mitigated",
2075        bullish_mitigated_arr.reshape((rows, cols))?,
2076    )?;
2077    dict.set_item("bullish_new", bullish_new_arr.reshape((rows, cols))?)?;
2078    dict.set_item("bearish_high", bearish_high_arr.reshape((rows, cols))?)?;
2079    dict.set_item("bearish_low", bearish_low_arr.reshape((rows, cols))?)?;
2080    dict.set_item("bearish_kind", bearish_kind_arr.reshape((rows, cols))?)?;
2081    dict.set_item("bearish_active", bearish_active_arr.reshape((rows, cols))?)?;
2082    dict.set_item(
2083        "bearish_mitigated",
2084        bearish_mitigated_arr.reshape((rows, cols))?,
2085    )?;
2086    dict.set_item("bearish_new", bearish_new_arr.reshape((rows, cols))?)?;
2087    dict.set_item("rows", rows)?;
2088    dict.set_item("cols", cols)?;
2089    dict.set_item("swing_lengths", swing_lengths.into_pyarray(py))?;
2090    dict.set_item("mitigation_prices", PyList::new(py, mitigation_prices)?)?;
2091    Ok(dict)
2092}
2093
2094#[cfg(feature = "python")]
2095pub fn register_ict_propulsion_block_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
2096    m.add_function(wrap_pyfunction!(ict_propulsion_block_py, m)?)?;
2097    m.add_function(wrap_pyfunction!(ict_propulsion_block_batch_py, m)?)?;
2098    m.add_class::<IctPropulsionBlockStreamPy>()?;
2099    Ok(())
2100}
2101
2102#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2103#[derive(Debug, Clone, Serialize, Deserialize)]
2104struct IctPropulsionBlockJsOutput {
2105    bullish_high: Vec<f64>,
2106    bullish_low: Vec<f64>,
2107    bullish_kind: Vec<f64>,
2108    bullish_active: Vec<f64>,
2109    bullish_mitigated: Vec<f64>,
2110    bullish_new: Vec<f64>,
2111    bearish_high: Vec<f64>,
2112    bearish_low: Vec<f64>,
2113    bearish_kind: Vec<f64>,
2114    bearish_active: Vec<f64>,
2115    bearish_mitigated: Vec<f64>,
2116    bearish_new: Vec<f64>,
2117}
2118
2119#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2120#[derive(Debug, Clone, Serialize, Deserialize)]
2121struct IctPropulsionBlockBatchConfig {
2122    swing_length_range: Vec<usize>,
2123    mitigation_price_toggle: Vec<bool>,
2124}
2125
2126#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2127#[derive(Debug, Clone, Serialize, Deserialize)]
2128struct IctPropulsionBlockBatchJsOutput {
2129    bullish_high: Vec<f64>,
2130    bullish_low: Vec<f64>,
2131    bullish_kind: Vec<f64>,
2132    bullish_active: Vec<f64>,
2133    bullish_mitigated: Vec<f64>,
2134    bullish_new: Vec<f64>,
2135    bearish_high: Vec<f64>,
2136    bearish_low: Vec<f64>,
2137    bearish_kind: Vec<f64>,
2138    bearish_active: Vec<f64>,
2139    bearish_mitigated: Vec<f64>,
2140    bearish_new: Vec<f64>,
2141    rows: usize,
2142    cols: usize,
2143    combos: Vec<IctPropulsionBlockParams>,
2144}
2145
2146#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2147#[wasm_bindgen(js_name = "ict_propulsion_block")]
2148pub fn ict_propulsion_block_js(
2149    open: &[f64],
2150    high: &[f64],
2151    low: &[f64],
2152    close: &[f64],
2153    swing_length: usize,
2154    mitigation_price: &str,
2155) -> Result<JsValue, JsValue> {
2156    let input = IctPropulsionBlockInput::from_slices(
2157        open,
2158        high,
2159        low,
2160        close,
2161        IctPropulsionBlockParams {
2162            swing_length: Some(swing_length),
2163            mitigation_price: Some(
2164                parse_mitigation_price(mitigation_price)
2165                    .map_err(|e| JsValue::from_str(&e.to_string()))?,
2166            ),
2167        },
2168    );
2169    let out = ict_propulsion_block_with_kernel(&input, Kernel::Scalar)
2170        .map_err(|e| JsValue::from_str(&e.to_string()))?;
2171    serde_wasm_bindgen::to_value(&IctPropulsionBlockJsOutput {
2172        bullish_high: out.bullish_high,
2173        bullish_low: out.bullish_low,
2174        bullish_kind: out.bullish_kind,
2175        bullish_active: out.bullish_active,
2176        bullish_mitigated: out.bullish_mitigated,
2177        bullish_new: out.bullish_new,
2178        bearish_high: out.bearish_high,
2179        bearish_low: out.bearish_low,
2180        bearish_kind: out.bearish_kind,
2181        bearish_active: out.bearish_active,
2182        bearish_mitigated: out.bearish_mitigated,
2183        bearish_new: out.bearish_new,
2184    })
2185    .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
2186}
2187
2188#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2189#[wasm_bindgen]
2190pub fn ict_propulsion_block_into(
2191    open_ptr: *const f64,
2192    high_ptr: *const f64,
2193    low_ptr: *const f64,
2194    close_ptr: *const f64,
2195    out_ptr: *mut f64,
2196    len: usize,
2197    swing_length: usize,
2198    mitigation_price: &str,
2199) -> Result<(), JsValue> {
2200    if open_ptr.is_null()
2201        || high_ptr.is_null()
2202        || low_ptr.is_null()
2203        || close_ptr.is_null()
2204        || out_ptr.is_null()
2205    {
2206        return Err(JsValue::from_str(
2207            "null pointer passed to ict_propulsion_block_into",
2208        ));
2209    }
2210
2211    unsafe {
2212        let open = std::slice::from_raw_parts(open_ptr, len);
2213        let high = std::slice::from_raw_parts(high_ptr, len);
2214        let low = std::slice::from_raw_parts(low_ptr, len);
2215        let close = std::slice::from_raw_parts(close_ptr, len);
2216        let out = std::slice::from_raw_parts_mut(out_ptr, len * 12);
2217        let (out_bullish_high, rest) = out.split_at_mut(len);
2218        let (out_bullish_low, rest) = rest.split_at_mut(len);
2219        let (out_bullish_kind, rest) = rest.split_at_mut(len);
2220        let (out_bullish_active, rest) = rest.split_at_mut(len);
2221        let (out_bullish_mitigated, rest) = rest.split_at_mut(len);
2222        let (out_bullish_new, rest) = rest.split_at_mut(len);
2223        let (out_bearish_high, rest) = rest.split_at_mut(len);
2224        let (out_bearish_low, rest) = rest.split_at_mut(len);
2225        let (out_bearish_kind, rest) = rest.split_at_mut(len);
2226        let (out_bearish_active, rest) = rest.split_at_mut(len);
2227        let (out_bearish_mitigated, out_bearish_new) = rest.split_at_mut(len);
2228        let input = IctPropulsionBlockInput::from_slices(
2229            open,
2230            high,
2231            low,
2232            close,
2233            IctPropulsionBlockParams {
2234                swing_length: Some(swing_length),
2235                mitigation_price: Some(
2236                    parse_mitigation_price(mitigation_price)
2237                        .map_err(|e| JsValue::from_str(&e.to_string()))?,
2238                ),
2239            },
2240        );
2241        ict_propulsion_block_into_slice(
2242            out_bullish_high,
2243            out_bullish_low,
2244            out_bullish_kind,
2245            out_bullish_active,
2246            out_bullish_mitigated,
2247            out_bullish_new,
2248            out_bearish_high,
2249            out_bearish_low,
2250            out_bearish_kind,
2251            out_bearish_active,
2252            out_bearish_mitigated,
2253            out_bearish_new,
2254            &input,
2255            Kernel::Scalar,
2256        )
2257        .map_err(|e| JsValue::from_str(&e.to_string()))
2258    }
2259}
2260
2261#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2262#[wasm_bindgen(js_name = "ict_propulsion_block_into_host")]
2263pub fn ict_propulsion_block_into_host(
2264    open: &[f64],
2265    high: &[f64],
2266    low: &[f64],
2267    close: &[f64],
2268    out_ptr: *mut f64,
2269    swing_length: usize,
2270    mitigation_price: &str,
2271) -> Result<(), JsValue> {
2272    if out_ptr.is_null() {
2273        return Err(JsValue::from_str(
2274            "null pointer passed to ict_propulsion_block_into_host",
2275        ));
2276    }
2277
2278    unsafe {
2279        let len = close.len();
2280        let out = std::slice::from_raw_parts_mut(out_ptr, len * 12);
2281        let (out_bullish_high, rest) = out.split_at_mut(len);
2282        let (out_bullish_low, rest) = rest.split_at_mut(len);
2283        let (out_bullish_kind, rest) = rest.split_at_mut(len);
2284        let (out_bullish_active, rest) = rest.split_at_mut(len);
2285        let (out_bullish_mitigated, rest) = rest.split_at_mut(len);
2286        let (out_bullish_new, rest) = rest.split_at_mut(len);
2287        let (out_bearish_high, rest) = rest.split_at_mut(len);
2288        let (out_bearish_low, rest) = rest.split_at_mut(len);
2289        let (out_bearish_kind, rest) = rest.split_at_mut(len);
2290        let (out_bearish_active, rest) = rest.split_at_mut(len);
2291        let (out_bearish_mitigated, out_bearish_new) = rest.split_at_mut(len);
2292        let input = IctPropulsionBlockInput::from_slices(
2293            open,
2294            high,
2295            low,
2296            close,
2297            IctPropulsionBlockParams {
2298                swing_length: Some(swing_length),
2299                mitigation_price: Some(
2300                    parse_mitigation_price(mitigation_price)
2301                        .map_err(|e| JsValue::from_str(&e.to_string()))?,
2302                ),
2303            },
2304        );
2305        ict_propulsion_block_into_slice(
2306            out_bullish_high,
2307            out_bullish_low,
2308            out_bullish_kind,
2309            out_bullish_active,
2310            out_bullish_mitigated,
2311            out_bullish_new,
2312            out_bearish_high,
2313            out_bearish_low,
2314            out_bearish_kind,
2315            out_bearish_active,
2316            out_bearish_mitigated,
2317            out_bearish_new,
2318            &input,
2319            Kernel::Scalar,
2320        )
2321        .map_err(|e| JsValue::from_str(&e.to_string()))
2322    }
2323}
2324
2325#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2326#[wasm_bindgen]
2327pub fn ict_propulsion_block_alloc(len: usize) -> *mut f64 {
2328    let mut buf = vec![0.0_f64; len * 12];
2329    let ptr = buf.as_mut_ptr();
2330    std::mem::forget(buf);
2331    ptr
2332}
2333
2334#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2335#[wasm_bindgen]
2336pub fn ict_propulsion_block_free(ptr: *mut f64, len: usize) {
2337    if ptr.is_null() {
2338        return;
2339    }
2340    unsafe {
2341        let _ = Vec::from_raw_parts(ptr, len * 12, len * 12);
2342    }
2343}
2344
2345#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2346#[wasm_bindgen(js_name = "ict_propulsion_block_batch")]
2347pub fn ict_propulsion_block_batch_js(
2348    open: &[f64],
2349    high: &[f64],
2350    low: &[f64],
2351    close: &[f64],
2352    config: JsValue,
2353) -> Result<JsValue, JsValue> {
2354    let config: IctPropulsionBlockBatchConfig = serde_wasm_bindgen::from_value(config)
2355        .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
2356    if config.swing_length_range.len() != 3 {
2357        return Err(JsValue::from_str(
2358            "Invalid config: swing_length_range must have exactly 3 elements [start, end, step]",
2359        ));
2360    }
2361    if config.mitigation_price_toggle.len() != 2 {
2362        return Err(JsValue::from_str(
2363            "Invalid config: mitigation_price_toggle must have exactly 2 booleans [include_close, include_wick]",
2364        ));
2365    }
2366
2367    let sweep = IctPropulsionBlockBatchRange {
2368        swing_length: (
2369            config.swing_length_range[0],
2370            config.swing_length_range[1],
2371            config.swing_length_range[2],
2372        ),
2373        mitigation_price: (
2374            config.mitigation_price_toggle[0],
2375            config.mitigation_price_toggle[1],
2376        ),
2377    };
2378    let out =
2379        ict_propulsion_block_batch_with_kernel(open, high, low, close, &sweep, Kernel::ScalarBatch)
2380            .map_err(|e| JsValue::from_str(&e.to_string()))?;
2381    serde_wasm_bindgen::to_value(&IctPropulsionBlockBatchJsOutput {
2382        bullish_high: out.bullish_high,
2383        bullish_low: out.bullish_low,
2384        bullish_kind: out.bullish_kind,
2385        bullish_active: out.bullish_active,
2386        bullish_mitigated: out.bullish_mitigated,
2387        bullish_new: out.bullish_new,
2388        bearish_high: out.bearish_high,
2389        bearish_low: out.bearish_low,
2390        bearish_kind: out.bearish_kind,
2391        bearish_active: out.bearish_active,
2392        bearish_mitigated: out.bearish_mitigated,
2393        bearish_new: out.bearish_new,
2394        rows: out.rows,
2395        cols: out.cols,
2396        combos: out.combos,
2397    })
2398    .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
2399}
2400
2401#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
2402#[wasm_bindgen]
2403#[allow(clippy::too_many_arguments)]
2404pub fn ict_propulsion_block_batch_into(
2405    open_ptr: *const f64,
2406    high_ptr: *const f64,
2407    low_ptr: *const f64,
2408    close_ptr: *const f64,
2409    bullish_high_ptr: *mut f64,
2410    bullish_low_ptr: *mut f64,
2411    bullish_kind_ptr: *mut f64,
2412    bullish_active_ptr: *mut f64,
2413    bullish_mitigated_ptr: *mut f64,
2414    bullish_new_ptr: *mut f64,
2415    bearish_high_ptr: *mut f64,
2416    bearish_low_ptr: *mut f64,
2417    bearish_kind_ptr: *mut f64,
2418    bearish_active_ptr: *mut f64,
2419    bearish_mitigated_ptr: *mut f64,
2420    bearish_new_ptr: *mut f64,
2421    len: usize,
2422    swing_start: usize,
2423    swing_end: usize,
2424    swing_step: usize,
2425    include_close: bool,
2426    include_wick: bool,
2427) -> Result<usize, JsValue> {
2428    if open_ptr.is_null()
2429        || high_ptr.is_null()
2430        || low_ptr.is_null()
2431        || close_ptr.is_null()
2432        || bullish_high_ptr.is_null()
2433        || bullish_low_ptr.is_null()
2434        || bullish_kind_ptr.is_null()
2435        || bullish_active_ptr.is_null()
2436        || bullish_mitigated_ptr.is_null()
2437        || bullish_new_ptr.is_null()
2438        || bearish_high_ptr.is_null()
2439        || bearish_low_ptr.is_null()
2440        || bearish_kind_ptr.is_null()
2441        || bearish_active_ptr.is_null()
2442        || bearish_mitigated_ptr.is_null()
2443        || bearish_new_ptr.is_null()
2444    {
2445        return Err(JsValue::from_str(
2446            "null pointer passed to ict_propulsion_block_batch_into",
2447        ));
2448    }
2449
2450    unsafe {
2451        let open = std::slice::from_raw_parts(open_ptr, len);
2452        let high = std::slice::from_raw_parts(high_ptr, len);
2453        let low = std::slice::from_raw_parts(low_ptr, len);
2454        let close = std::slice::from_raw_parts(close_ptr, len);
2455        let sweep = IctPropulsionBlockBatchRange {
2456            swing_length: (swing_start, swing_end, swing_step),
2457            mitigation_price: (include_close, include_wick),
2458        };
2459        let combos = expand_grid_ict_propulsion_block(&sweep)
2460            .map_err(|e| JsValue::from_str(&e.to_string()))?;
2461        let total = combos.len().checked_mul(len).ok_or_else(|| {
2462            JsValue::from_str("rows*cols overflow in ict_propulsion_block_batch_into")
2463        })?;
2464
2465        let out_bullish_high = std::slice::from_raw_parts_mut(bullish_high_ptr, total);
2466        let out_bullish_low = std::slice::from_raw_parts_mut(bullish_low_ptr, total);
2467        let out_bullish_kind = std::slice::from_raw_parts_mut(bullish_kind_ptr, total);
2468        let out_bullish_active = std::slice::from_raw_parts_mut(bullish_active_ptr, total);
2469        let out_bullish_mitigated = std::slice::from_raw_parts_mut(bullish_mitigated_ptr, total);
2470        let out_bullish_new = std::slice::from_raw_parts_mut(bullish_new_ptr, total);
2471        let out_bearish_high = std::slice::from_raw_parts_mut(bearish_high_ptr, total);
2472        let out_bearish_low = std::slice::from_raw_parts_mut(bearish_low_ptr, total);
2473        let out_bearish_kind = std::slice::from_raw_parts_mut(bearish_kind_ptr, total);
2474        let out_bearish_active = std::slice::from_raw_parts_mut(bearish_active_ptr, total);
2475        let out_bearish_mitigated = std::slice::from_raw_parts_mut(bearish_mitigated_ptr, total);
2476        let out_bearish_new = std::slice::from_raw_parts_mut(bearish_new_ptr, total);
2477
2478        ict_propulsion_block_batch_inner_into(
2479            open,
2480            high,
2481            low,
2482            close,
2483            &sweep,
2484            Kernel::Scalar,
2485            out_bullish_high,
2486            out_bullish_low,
2487            out_bullish_kind,
2488            out_bullish_active,
2489            out_bullish_mitigated,
2490            out_bullish_new,
2491            out_bearish_high,
2492            out_bearish_low,
2493            out_bearish_kind,
2494            out_bearish_active,
2495            out_bearish_mitigated,
2496            out_bearish_new,
2497        )
2498        .map_err(|e| JsValue::from_str(&e.to_string()))?;
2499        Ok(combos.len())
2500    }
2501}
2502
2503#[cfg(test)]
2504mod tests {
2505    use super::*;
2506    use crate::indicators::dispatch::{
2507        compute_cpu_batch, IndicatorBatchRequest, IndicatorDataRef, IndicatorParamSet, ParamKV,
2508        ParamValue,
2509    };
2510    use crate::utilities::data_loader::read_candles_from_csv;
2511    use crate::utilities::enums::Kernel;
2512
2513    fn load_candles() -> Candles {
2514        read_candles_from_csv("src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv")
2515            .expect("test candles")
2516    }
2517
2518    fn eq_or_both_nan(lhs: &[f64], rhs: &[f64]) -> bool {
2519        lhs.iter()
2520            .zip(rhs.iter())
2521            .all(|(a, b)| (a.is_nan() && b.is_nan()) || a == b)
2522    }
2523
2524    #[test]
2525    fn output_contract() {
2526        let candles = load_candles();
2527        let input = IctPropulsionBlockInput::from_slices(
2528            &candles.open[..320],
2529            &candles.high[..320],
2530            &candles.low[..320],
2531            &candles.close[..320],
2532            IctPropulsionBlockParams::default(),
2533        );
2534        let out = ict_propulsion_block(&input).expect("ict_propulsion_block");
2535        assert_eq!(out.bullish_high.len(), 320);
2536        assert_eq!(out.bearish_high.len(), 320);
2537        assert!(out
2538            .bullish_kind
2539            .iter()
2540            .any(|v| v.is_finite() && (*v == 1.0 || *v == 2.0)));
2541        for &kind in out.bullish_kind.iter().chain(out.bearish_kind.iter()) {
2542            assert!(kind.is_nan() || kind == 0.0 || kind == 1.0 || kind == 2.0);
2543        }
2544    }
2545
2546    #[test]
2547    fn invalid_params() {
2548        let candles = load_candles();
2549        let input = IctPropulsionBlockInput::from_slices(
2550            &candles.open[..64],
2551            &candles.high[..64],
2552            &candles.low[..64],
2553            &candles.close[..64],
2554            IctPropulsionBlockParams {
2555                swing_length: Some(0),
2556                mitigation_price: Some(IctPropulsionBlockMitigationPrice::Close),
2557            },
2558        );
2559        assert!(matches!(
2560            ict_propulsion_block(&input),
2561            Err(IctPropulsionBlockError::InvalidSwingLength { swing_length: 0 })
2562        ));
2563    }
2564
2565    #[test]
2566    fn into_matches_direct() {
2567        let candles = load_candles();
2568        let input = IctPropulsionBlockInput::from_slices(
2569            &candles.open[..220],
2570            &candles.high[..220],
2571            &candles.low[..220],
2572            &candles.close[..220],
2573            IctPropulsionBlockParams::default(),
2574        );
2575        let direct = ict_propulsion_block(&input).expect("direct");
2576        let mut bullish_high = vec![f64::NAN; 220];
2577        let mut bullish_low = vec![f64::NAN; 220];
2578        let mut bullish_kind = vec![f64::NAN; 220];
2579        let mut bullish_active = vec![f64::NAN; 220];
2580        let mut bullish_mitigated = vec![f64::NAN; 220];
2581        let mut bullish_new = vec![f64::NAN; 220];
2582        let mut bearish_high = vec![f64::NAN; 220];
2583        let mut bearish_low = vec![f64::NAN; 220];
2584        let mut bearish_kind = vec![f64::NAN; 220];
2585        let mut bearish_active = vec![f64::NAN; 220];
2586        let mut bearish_mitigated = vec![f64::NAN; 220];
2587        let mut bearish_new = vec![f64::NAN; 220];
2588
2589        ict_propulsion_block_into_slice(
2590            &mut bullish_high,
2591            &mut bullish_low,
2592            &mut bullish_kind,
2593            &mut bullish_active,
2594            &mut bullish_mitigated,
2595            &mut bullish_new,
2596            &mut bearish_high,
2597            &mut bearish_low,
2598            &mut bearish_kind,
2599            &mut bearish_active,
2600            &mut bearish_mitigated,
2601            &mut bearish_new,
2602            &input,
2603            Kernel::Scalar,
2604        )
2605        .expect("into");
2606
2607        assert!(eq_or_both_nan(&bullish_high, &direct.bullish_high));
2608        assert!(eq_or_both_nan(&bearish_kind, &direct.bearish_kind));
2609        assert!(eq_or_both_nan(&bullish_new, &direct.bullish_new));
2610    }
2611
2612    #[test]
2613    fn stream_matches_batch() {
2614        let candles = load_candles();
2615        let open = &candles.open[..180];
2616        let high = &candles.high[..180];
2617        let low = &candles.low[..180];
2618        let close = &candles.close[..180];
2619        let input = IctPropulsionBlockInput::from_slices(
2620            open,
2621            high,
2622            low,
2623            close,
2624            IctPropulsionBlockParams::default(),
2625        );
2626        let batch = ict_propulsion_block(&input).expect("batch");
2627        let mut stream =
2628            IctPropulsionBlockStream::try_new(IctPropulsionBlockParams::default()).expect("stream");
2629        let mut bullish_high = Vec::new();
2630        let mut bearish_new = Vec::new();
2631        for i in 0..open.len() {
2632            let out = stream
2633                .update(open[i], high[i], low[i], close[i])
2634                .expect("stream update");
2635            bullish_high.push(out.0);
2636            bearish_new.push(out.11);
2637        }
2638        assert!(eq_or_both_nan(&bullish_high, &batch.bullish_high));
2639        assert!(eq_or_both_nan(&bearish_new, &batch.bearish_new));
2640    }
2641
2642    #[test]
2643    fn batch_single_param_matches_single() {
2644        let candles = load_candles();
2645        let open = &candles.open[..160];
2646        let high = &candles.high[..160];
2647        let low = &candles.low[..160];
2648        let close = &candles.close[..160];
2649        let batch = ict_propulsion_block_batch_with_kernel(
2650            open,
2651            high,
2652            low,
2653            close,
2654            &IctPropulsionBlockBatchRange {
2655                swing_length: (3, 3, 0),
2656                mitigation_price: (true, false),
2657            },
2658            Kernel::ScalarBatch,
2659        )
2660        .expect("batch");
2661        let single = ict_propulsion_block(&IctPropulsionBlockInput::from_slices(
2662            open,
2663            high,
2664            low,
2665            close,
2666            IctPropulsionBlockParams::default(),
2667        ))
2668        .expect("single");
2669        assert_eq!(batch.rows, 1);
2670        assert_eq!(batch.cols, close.len());
2671        assert!(eq_or_both_nan(
2672            &batch.bullish_high[..close.len()],
2673            &single.bullish_high[..]
2674        ));
2675        assert!(eq_or_both_nan(
2676            &batch.bearish_low[..close.len()],
2677            &single.bearish_low[..]
2678        ));
2679    }
2680
2681    #[test]
2682    fn dispatch_matches_direct() {
2683        let candles = load_candles();
2684        let combos = [IndicatorParamSet {
2685            params: &[
2686                ParamKV {
2687                    key: "swing_length",
2688                    value: ParamValue::Int(3),
2689                },
2690                ParamKV {
2691                    key: "mitigation_price",
2692                    value: ParamValue::EnumString("close"),
2693                },
2694            ],
2695        }];
2696        let dispatched = compute_cpu_batch(IndicatorBatchRequest {
2697            indicator_id: "ict_propulsion_block",
2698            output_id: Some("bullish_high"),
2699            data: IndicatorDataRef::Ohlc {
2700                open: &candles.open[..160],
2701                high: &candles.high[..160],
2702                low: &candles.low[..160],
2703                close: &candles.close[..160],
2704            },
2705            combos: &combos,
2706            kernel: Kernel::ScalarBatch,
2707        })
2708        .expect("dispatch");
2709        let direct = ict_propulsion_block(&IctPropulsionBlockInput::from_slices(
2710            &candles.open[..160],
2711            &candles.high[..160],
2712            &candles.low[..160],
2713            &candles.close[..160],
2714            IctPropulsionBlockParams::default(),
2715        ))
2716        .expect("direct");
2717        assert!(eq_or_both_nan(
2718            &dispatched.values_f64.expect("f64"),
2719            &direct.bullish_high
2720        ));
2721    }
2722}