Skip to main content

vector_ta/indicators/
fvg_positioning_average.rs

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