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: ¶ms,
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: ¶ms,
2253 kernel: Kernel::Scalar,
2254 })?;
2255 assert_eq!(bear_out.output_id, "bear_mid");
2256 Ok(())
2257 }
2258}