Skip to main content

vector_ta/indicators/moving_averages/
ma_batch.rs

1use super::ma::MaData;
2use crate::utilities::data_loader::source_type;
3use crate::utilities::enums::Kernel;
4use std::collections::HashMap;
5use std::error::Error;
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9pub enum MaBatchDispatchError {
10    #[error("Unknown moving average type: {ma_type}")]
11    UnknownType { ma_type: String },
12    #[error(
13        "{indicator} does not support period-sweep batch dispatch; use the indicator directly"
14    )]
15    NotPeriodBased { indicator: &'static str },
16    #[error("{indicator} requires candles (timestamp/volume/OHLC); pass MaData::Candles")]
17    RequiresCandles { indicator: &'static str },
18    #[error("invalid param '{key}' for {indicator}: value={value} ({reason})")]
19    InvalidParam {
20        indicator: &'static str,
21        key: &'static str,
22        value: f64,
23        reason: &'static str,
24    },
25    #[error("invalid kernel for batch path: {0:?}")]
26    InvalidKernelForBatch(Kernel),
27}
28
29#[derive(Clone, Debug)]
30pub struct MaBatchOutput {
31    pub values: Vec<f64>,
32    pub periods: Vec<usize>,
33    pub rows: usize,
34    pub cols: usize,
35}
36
37impl MaBatchOutput {
38    pub fn row_for_period(&self, period: usize) -> Option<usize> {
39        self.periods.iter().position(|&p| p == period)
40    }
41
42    pub fn values_for_period(&self, period: usize) -> Option<&[f64]> {
43        self.row_for_period(period).map(|row| {
44            let start = row * self.cols;
45            &self.values[start..start + self.cols]
46        })
47    }
48}
49
50#[derive(Clone, Debug)]
51pub enum MaBatchParamValue<'a> {
52    Int(i64),
53    Float(f64),
54    Bool(bool),
55    EnumString(&'a str),
56}
57
58#[derive(Clone, Debug)]
59pub struct MaBatchParamKV<'a> {
60    pub key: &'a str,
61    pub value: MaBatchParamValue<'a>,
62}
63
64#[inline]
65fn to_batch_kernel(k: Kernel) -> Result<Kernel, MaBatchDispatchError> {
66    let out = match k {
67        Kernel::Auto => Kernel::Auto,
68        Kernel::Scalar => Kernel::ScalarBatch,
69        Kernel::Avx2 => Kernel::Avx2Batch,
70        Kernel::Avx512 => Kernel::Avx512Batch,
71        other if other.is_batch() => other,
72        other => return Err(MaBatchDispatchError::InvalidKernelForBatch(other)),
73    };
74    Ok(out)
75}
76
77#[inline]
78fn map_periods<T>(combos: &[T], get_period: impl Fn(&T) -> usize) -> Vec<usize> {
79    combos.iter().map(get_period).collect()
80}
81
82#[inline]
83fn expand_period_axis(range: (usize, usize, usize)) -> Result<Vec<usize>, MaBatchDispatchError> {
84    let (start, end, step) = range;
85    let periods = if step == 0 || start == end {
86        vec![start]
87    } else if start < end {
88        let s = step.max(1);
89        (start..=end).step_by(s).collect()
90    } else {
91        let s = step.max(1);
92        let mut v = Vec::new();
93        let mut cur = start;
94        while cur >= end {
95            v.push(cur);
96            if cur == 0 {
97                break;
98            }
99            let next = cur.saturating_sub(s);
100            if next == cur {
101                break;
102            }
103            cur = next;
104            if cur < end {
105                break;
106            }
107        }
108        v
109    };
110    if periods.is_empty() {
111        return Err(MaBatchDispatchError::InvalidParam {
112            indicator: "period_range",
113            key: "step",
114            value: step as f64,
115            reason: "invalid period range",
116        });
117    }
118    Ok(periods)
119}
120
121#[inline]
122pub fn ma_batch<'a>(
123    ma_type: &str,
124    data: MaData<'a>,
125    period_range: (usize, usize, usize),
126) -> Result<MaBatchOutput, Box<dyn Error>> {
127    ma_batch_with_kernel(ma_type, data, period_range, Kernel::Auto)
128}
129
130pub fn ma_batch_with_kernel<'a>(
131    ma_type: &str,
132    data: MaData<'a>,
133    period_range: (usize, usize, usize),
134    kernel: Kernel,
135) -> Result<MaBatchOutput, Box<dyn Error>> {
136    ma_batch_with_kernel_and_params(ma_type, data, period_range, kernel, None)
137}
138
139#[inline]
140pub fn ma_batch_with_params<'a>(
141    ma_type: &str,
142    data: MaData<'a>,
143    period_range: (usize, usize, usize),
144    params: &HashMap<String, f64>,
145) -> Result<MaBatchOutput, Box<dyn Error>> {
146    ma_batch_with_kernel_and_params(ma_type, data, period_range, Kernel::Auto, Some(params))
147}
148
149pub fn ma_batch_with_kernel_and_typed_params<'a>(
150    ma_type: &str,
151    data: MaData<'a>,
152    period_range: (usize, usize, usize),
153    kernel: Kernel,
154    params: &[MaBatchParamKV<'_>],
155) -> Result<MaBatchOutput, Box<dyn Error>> {
156    let mut numeric: HashMap<String, f64> = HashMap::with_capacity(params.len());
157    let mut text: HashMap<String, String> = HashMap::new();
158
159    for p in params {
160        match p.value {
161            MaBatchParamValue::Int(v) => {
162                numeric.insert(p.key.to_string(), v as f64);
163            }
164            MaBatchParamValue::Float(v) => {
165                if !v.is_finite() {
166                    return Err(MaBatchDispatchError::InvalidParam {
167                        indicator: "typed_params",
168                        key: "float",
169                        value: v,
170                        reason: "expected finite number",
171                    }
172                    .into());
173                }
174                numeric.insert(p.key.to_string(), v);
175            }
176            MaBatchParamValue::Bool(v) => {
177                numeric.insert(p.key.to_string(), if v { 1.0 } else { 0.0 });
178            }
179            MaBatchParamValue::EnumString(v) => {
180                text.insert(p.key.to_string(), v.to_string());
181            }
182        }
183    }
184
185    if ma_type.eq_ignore_ascii_case("dma") && text.contains_key("hull_ma_type") {
186        let kernel = to_batch_kernel(kernel)?;
187        let (prices, _) = match data {
188            MaData::Slice(s) => (s, None),
189            MaData::Candles { candles, source } => (source_type(candles, source), Some(candles)),
190        };
191
192        let get_u = |key: &'static str, default_v: usize| -> Result<usize, MaBatchDispatchError> {
193            let Some(v) = numeric.get(key).copied() else {
194                return Ok(default_v);
195            };
196            if v < 0.0 {
197                return Err(MaBatchDispatchError::InvalidParam {
198                    indicator: "dma",
199                    key,
200                    value: v,
201                    reason: "expected >= 0",
202                });
203            }
204            let r = v.round();
205            if (v - r).abs() > 1e-9 {
206                return Err(MaBatchDispatchError::InvalidParam {
207                    indicator: "dma",
208                    key,
209                    value: v,
210                    reason: "expected integer",
211                });
212            }
213            Ok(r as usize)
214        };
215
216        let ema_length = get_u("ema_length", 20)?;
217        let ema_gain_limit = get_u("ema_gain_limit", 50)?;
218        let hull_ma_type = text
219            .get("hull_ma_type")
220            .cloned()
221            .unwrap_or_else(|| "WMA".to_string());
222        let sweep = super::dma::DmaBatchRange {
223            hull_length: period_range,
224            ema_length: (ema_length, ema_length, 0),
225            ema_gain_limit: (ema_gain_limit, ema_gain_limit, 0),
226            hull_ma_type,
227        };
228        let out = super::dma::dma_batch_with_kernel(prices, &sweep, kernel)?;
229        return Ok(MaBatchOutput {
230            periods: map_periods(&out.combos, |p| p.hull_length.unwrap_or(7)),
231            values: out.values,
232            rows: out.rows,
233            cols: out.cols,
234        });
235    }
236
237    if ma_type.eq_ignore_ascii_case("vwap")
238        && (text.contains_key("anchor")
239            || text.contains_key("anchor_start")
240            || text.contains_key("anchor_end"))
241    {
242        let kernel = to_batch_kernel(kernel)?;
243        let (prices, candles) = match data {
244            MaData::Slice(s) => (s, None),
245            MaData::Candles { candles, source } => (source_type(candles, source), Some(candles)),
246        };
247        let candles = candles.ok_or(MaBatchDispatchError::RequiresCandles { indicator: "vwap" })?;
248
249        let single_anchor = text.get("anchor").cloned();
250        let anchor_start = text
251            .get("anchor_start")
252            .cloned()
253            .or_else(|| single_anchor.clone())
254            .unwrap_or_else(|| "1d".to_string());
255        let anchor_end = text
256            .get("anchor_end")
257            .cloned()
258            .or_else(|| single_anchor.clone())
259            .unwrap_or_else(|| anchor_start.clone());
260        let anchor_step = numeric
261            .get("anchor_step")
262            .copied()
263            .map(|v| {
264                if v < 0.0 {
265                    return Err(MaBatchDispatchError::InvalidParam {
266                        indicator: "vwap",
267                        key: "anchor_step",
268                        value: v,
269                        reason: "expected >= 0",
270                    });
271                }
272                let r = v.round();
273                if (v - r).abs() > 1e-9 {
274                    return Err(MaBatchDispatchError::InvalidParam {
275                        indicator: "vwap",
276                        key: "anchor_step",
277                        value: v,
278                        reason: "expected integer",
279                    });
280                }
281                Ok(r as u32)
282            })
283            .transpose()?
284            .unwrap_or_else(|| if anchor_start == anchor_end { 0 } else { 1 });
285
286        let sweep = super::vwap::VwapBatchRange {
287            anchor: (anchor_start, anchor_end, anchor_step),
288        };
289        let out = super::vwap::vwap_batch_with_kernel(
290            &candles.timestamp,
291            &candles.volume,
292            prices,
293            &sweep,
294            kernel,
295        )?;
296        let periods = out
297            .combos
298            .iter()
299            .enumerate()
300            .map(|(i, p)| {
301                p.anchor
302                    .as_deref()
303                    .and_then(|a| super::vwap::parse_anchor(a).ok().map(|(n, _)| n as usize))
304                    .unwrap_or(i + 1)
305            })
306            .collect();
307        return Ok(MaBatchOutput {
308            periods,
309            values: out.values,
310            rows: out.rows,
311            cols: out.cols,
312        });
313    }
314
315    if ma_type.eq_ignore_ascii_case("mama") {
316        let kernel = to_batch_kernel(kernel)?;
317        let (prices, _) = match data {
318            MaData::Slice(s) => (s, None),
319            MaData::Candles { candles, source } => (source_type(candles, source), Some(candles)),
320        };
321        let mut sweep = super::mama::MamaBatchRange::default();
322        if let Some(v) = numeric.get("fast_limit").copied() {
323            sweep.fast_limit = (v, v, 0.0);
324        } else {
325            if let Some(v) = numeric.get("fast_limit_start").copied() {
326                sweep.fast_limit.0 = v;
327            }
328            if let Some(v) = numeric.get("fast_limit_end").copied() {
329                sweep.fast_limit.1 = v;
330            }
331            if let Some(v) = numeric.get("fast_limit_step").copied() {
332                sweep.fast_limit.2 = v;
333            }
334        }
335        if let Some(v) = numeric.get("slow_limit").copied() {
336            sweep.slow_limit = (v, v, 0.0);
337        } else {
338            if let Some(v) = numeric.get("slow_limit_start").copied() {
339                sweep.slow_limit.0 = v;
340            }
341            if let Some(v) = numeric.get("slow_limit_end").copied() {
342                sweep.slow_limit.1 = v;
343            }
344            if let Some(v) = numeric.get("slow_limit_step").copied() {
345                sweep.slow_limit.2 = v;
346            }
347        }
348        let out = super::mama::mama_batch_with_kernel(prices, &sweep, kernel)?;
349        let output = text
350            .get("output")
351            .map(String::as_str)
352            .unwrap_or("mama")
353            .to_ascii_lowercase();
354        let values = match output.as_str() {
355            "mama" => out.mama_values,
356            "fama" => out.fama_values,
357            _ => {
358                return Err(MaBatchDispatchError::InvalidParam {
359                    indicator: "mama",
360                    key: "output",
361                    value: f64::NAN,
362                    reason: "expected 'mama' or 'fama'",
363                }
364                .into())
365            }
366        };
367        return Ok(MaBatchOutput {
368            periods: (1..=out.rows).collect(),
369            values,
370            rows: out.rows,
371            cols: out.cols,
372        });
373    }
374
375    if ma_type.eq_ignore_ascii_case("ehlers_pma") {
376        let kernel = to_batch_kernel(kernel)?;
377        let periods = expand_period_axis(period_range)?;
378        let rows = periods.len();
379        let (prices, _) = match data {
380            MaData::Slice(s) => (s, None),
381            MaData::Candles { candles, source } => (source_type(candles, source), Some(candles)),
382        };
383        let input = super::ehlers_pma::EhlersPmaInput::from_slice(
384            prices,
385            super::ehlers_pma::EhlersPmaParams::default(),
386        );
387        let out = super::ehlers_pma::ehlers_pma_with_kernel(&input, kernel)?;
388        let output = text
389            .get("output")
390            .map(String::as_str)
391            .unwrap_or("predict")
392            .to_ascii_lowercase();
393        let series = match output.as_str() {
394            "predict" => &out.predict,
395            "trigger" => &out.trigger,
396            _ => {
397                return Err(MaBatchDispatchError::InvalidParam {
398                    indicator: "ehlers_pma",
399                    key: "output",
400                    value: f64::NAN,
401                    reason: "expected 'predict' or 'trigger'",
402                }
403                .into())
404            }
405        };
406        let cols = series.len();
407        let mut values = Vec::with_capacity(rows.saturating_mul(cols));
408        for _ in 0..rows {
409            values.extend_from_slice(series);
410        }
411        return Ok(MaBatchOutput {
412            periods,
413            values,
414            rows,
415            cols,
416        });
417    }
418
419    if ma_type.eq_ignore_ascii_case("ema_deviation_corrected_t3") {
420        let kernel = to_batch_kernel(kernel)?;
421        let (prices, _) = match data {
422            MaData::Slice(s) => (s, None),
423            MaData::Candles { candles, source } => (source_type(candles, source), Some(candles)),
424        };
425        let sweep = super::ema_deviation_corrected_t3::EmaDeviationCorrectedT3BatchRange {
426            period: period_range,
427            hot: {
428                let v = numeric.get("hot").copied().unwrap_or(0.7);
429                (v, v, 0.0)
430            },
431            t3_mode: {
432                let v = numeric.get("t3_mode").copied().unwrap_or(0.0);
433                if v < 0.0 || (v - v.round()).abs() > 1e-9 {
434                    return Err(MaBatchDispatchError::InvalidParam {
435                        indicator: "ema_deviation_corrected_t3",
436                        key: "t3_mode",
437                        value: v,
438                        reason: "expected integer >= 0",
439                    }
440                    .into());
441                }
442                let v = v.round() as usize;
443                (v, v, 0)
444            },
445        };
446        let out = super::ema_deviation_corrected_t3::ema_deviation_corrected_t3_batch_with_kernel(
447            prices, &sweep, kernel,
448        )?;
449        let output = text
450            .get("output")
451            .map(String::as_str)
452            .unwrap_or("corrected")
453            .to_ascii_lowercase();
454        let periods = map_periods(&out.combos, |p| p.period.unwrap_or(10));
455        let rows = out.rows;
456        let cols = out.cols;
457        let series = match output.as_str() {
458            "corrected" | "value" => out.corrected,
459            "t3" => out.t3,
460            _ => {
461                return Err(MaBatchDispatchError::InvalidParam {
462                    indicator: "ema_deviation_corrected_t3",
463                    key: "output",
464                    value: f64::NAN,
465                    reason: "expected 'corrected' or 't3'",
466                }
467                .into())
468            }
469        };
470        return Ok(MaBatchOutput {
471            periods,
472            values: series,
473            rows,
474            cols,
475        });
476    }
477
478    if ma_type.eq_ignore_ascii_case("ehlers_undersampled_double_moving_average") {
479        let kernel = to_batch_kernel(kernel)?;
480        let (prices, _) = match data {
481            MaData::Slice(s) => (s, None),
482            MaData::Candles { candles, source } => (source_type(candles, source), Some(candles)),
483        };
484
485        let get_u = |key: &'static str, default_v: usize| -> Result<usize, MaBatchDispatchError> {
486            let Some(v) = numeric.get(key).copied() else {
487                return Ok(default_v);
488            };
489            if v < 0.0 {
490                return Err(MaBatchDispatchError::InvalidParam {
491                    indicator: "ehlers_undersampled_double_moving_average",
492                    key,
493                    value: v,
494                    reason: "expected >= 0",
495                });
496            }
497            let r = v.round();
498            if (v - r).abs() > 1e-9 {
499                return Err(MaBatchDispatchError::InvalidParam {
500                    indicator: "ehlers_undersampled_double_moving_average",
501                    key,
502                    value: v,
503                    reason: "expected integer",
504                });
505            }
506            Ok(r as usize)
507        };
508
509        let mut sweep =
510            super::ehlers_undersampled_double_moving_average::EhlersUndersampledDoubleMovingAverageBatchRange::default();
511        sweep.fast_length = (
512            get_u("fast_length_start", get_u("fast_length", 6)?)?,
513            get_u("fast_length_end", get_u("fast_length", 6)?)?,
514            get_u("fast_length_step", 0)?,
515        );
516        sweep.slow_length = (
517            get_u("slow_length_start", get_u("slow_length", 12)?)?,
518            get_u("slow_length_end", get_u("slow_length", 12)?)?,
519            get_u("slow_length_step", 0)?,
520        );
521        sweep.sample_length = (
522            get_u("sample_length_start", get_u("sample_length", 5)?)?,
523            get_u("sample_length_end", get_u("sample_length", 5)?)?,
524            get_u("sample_length_step", 0)?,
525        );
526
527        let out =
528            super::ehlers_undersampled_double_moving_average::ehlers_undersampled_double_moving_average_batch_with_kernel(
529                prices, &sweep, kernel,
530            )?;
531        let output = text
532            .get("output")
533            .map(String::as_str)
534            .unwrap_or("fast")
535            .to_ascii_lowercase();
536        let values = match output.as_str() {
537            "fast" => out.fast_values,
538            "slow" => out.slow_values,
539            _ => {
540                return Err(MaBatchDispatchError::InvalidParam {
541                    indicator: "ehlers_undersampled_double_moving_average",
542                    key: "output",
543                    value: f64::NAN,
544                    reason: "expected 'fast' or 'slow'",
545                }
546                .into())
547            }
548        };
549
550        return Ok(MaBatchOutput {
551            periods: (1..=out.rows).collect(),
552            values,
553            rows: out.rows,
554            cols: out.cols,
555        });
556    }
557
558    if ma_type.eq_ignore_ascii_case("buff_averages") {
559        let kernel = to_batch_kernel(kernel)?;
560        let (prices, candles) = match data {
561            MaData::Slice(s) => (s, None),
562            MaData::Candles { candles, source } => (source_type(candles, source), Some(candles)),
563        };
564        let candles = candles.ok_or(MaBatchDispatchError::RequiresCandles {
565            indicator: "buff_averages",
566        })?;
567
568        let get_u = |key: &'static str| -> Result<Option<usize>, MaBatchDispatchError> {
569            let Some(v) = numeric.get(key).copied() else {
570                return Ok(None);
571            };
572            if v < 0.0 {
573                return Err(MaBatchDispatchError::InvalidParam {
574                    indicator: "buff_averages",
575                    key,
576                    value: v,
577                    reason: "expected >= 0",
578                });
579            }
580            let r = v.round();
581            if (v - r).abs() > 1e-9 {
582                return Err(MaBatchDispatchError::InvalidParam {
583                    indicator: "buff_averages",
584                    key,
585                    value: v,
586                    reason: "expected integer",
587                });
588            }
589            Ok(Some(r as usize))
590        };
591
592        let mut sweep = super::buff_averages::BuffAveragesBatchRange::default();
593        sweep.slow_period = period_range;
594
595        if let Some(v) = get_u("fast_period")? {
596            sweep.fast_period = (v, v, 0);
597        }
598        if let Some(v) = get_u("slow_period")? {
599            sweep.slow_period = (v, v, 0);
600        }
601        if let Some(v) = get_u("fast_period_start")? {
602            sweep.fast_period.0 = v;
603        }
604        if let Some(v) = get_u("fast_period_end")? {
605            sweep.fast_period.1 = v;
606        }
607        if let Some(v) = get_u("fast_period_step")? {
608            sweep.fast_period.2 = v;
609        }
610        if let Some(v) = get_u("slow_period_start")? {
611            sweep.slow_period.0 = v;
612        }
613        if let Some(v) = get_u("slow_period_end")? {
614            sweep.slow_period.1 = v;
615        }
616        if let Some(v) = get_u("slow_period_step")? {
617            sweep.slow_period.2 = v;
618        }
619
620        let out = super::buff_averages::buff_averages_batch_with_kernel(
621            prices,
622            &candles.volume,
623            &sweep,
624            kernel,
625        )?;
626
627        let output = text
628            .get("output")
629            .map(String::as_str)
630            .unwrap_or("fast")
631            .to_ascii_lowercase();
632        let values = match output.as_str() {
633            "fast" | "fast_buff" => out.fast,
634            "slow" | "slow_buff" => out.slow,
635            _ => {
636                return Err(MaBatchDispatchError::InvalidParam {
637                    indicator: "buff_averages",
638                    key: "output",
639                    value: f64::NAN,
640                    reason: "expected 'fast' or 'slow'",
641                }
642                .into())
643            }
644        };
645
646        let all_fast_same = out
647            .combos
648            .first()
649            .map(|c| out.combos.iter().all(|x| x.0 == c.0))
650            .unwrap_or(true);
651        let all_slow_same = out
652            .combos
653            .first()
654            .map(|c| out.combos.iter().all(|x| x.1 == c.1))
655            .unwrap_or(true);
656        let periods = if all_fast_same {
657            out.combos.iter().map(|c| c.1).collect()
658        } else if all_slow_same {
659            out.combos.iter().map(|c| c.0).collect()
660        } else {
661            (1..=out.rows).collect()
662        };
663
664        return Ok(MaBatchOutput {
665            periods,
666            values,
667            rows: out.rows,
668            cols: out.cols,
669        });
670    }
671
672    if ma_type.eq_ignore_ascii_case("n_order_ema") {
673        let kernel = to_batch_kernel(kernel)?;
674        let (prices, _) = match data {
675            MaData::Slice(s) => (s, None),
676            MaData::Candles { candles, source } => (source_type(candles, source), Some(candles)),
677        };
678
679        let order = match numeric.get("order").copied() {
680            Some(v) => {
681                if v <= 0.0 {
682                    return Err(MaBatchDispatchError::InvalidParam {
683                        indicator: "n_order_ema",
684                        key: "order",
685                        value: v,
686                        reason: "expected integer > 0",
687                    }
688                    .into());
689                }
690                let r = v.round();
691                if (v - r).abs() > 1.0e-9 {
692                    return Err(MaBatchDispatchError::InvalidParam {
693                        indicator: "n_order_ema",
694                        key: "order",
695                        value: v,
696                        reason: "expected integer",
697                    }
698                    .into());
699                }
700                r as usize
701            }
702            None => 1usize,
703        };
704
705        let ema_style = text
706            .get("ema_style")
707            .cloned()
708            .unwrap_or_else(|| "ema".to_string());
709        let iir_style = text
710            .get("iir_style")
711            .cloned()
712            .unwrap_or_else(|| "impulse_matched".to_string());
713
714        let out = super::n_order_ema::n_order_ema_batch_with_kernel(
715            prices,
716            &super::n_order_ema::NOrderEmaBatchRange {
717                period: (
718                    period_range.0 as f64,
719                    period_range.1 as f64,
720                    period_range.2 as f64,
721                ),
722                order: (order, order, 0),
723            },
724            &super::n_order_ema::NOrderEmaParams {
725                period: None,
726                order: None,
727                ema_style: Some(ema_style),
728                iir_style: Some(iir_style),
729            },
730            kernel,
731        )?;
732        return Ok(MaBatchOutput {
733            periods: map_periods(&out.combos, |p| p.period.unwrap_or(9.0).round() as usize),
734            values: out.values,
735            rows: out.rows,
736            cols: out.cols,
737        });
738    }
739
740    ma_batch_with_kernel_and_params(ma_type, data, period_range, kernel, Some(&numeric))
741}
742
743pub fn ma_batch_with_kernel_and_params<'a>(
744    ma_type: &str,
745    data: MaData<'a>,
746    period_range: (usize, usize, usize),
747    kernel: Kernel,
748    params: Option<&HashMap<String, f64>>,
749) -> Result<MaBatchOutput, Box<dyn Error>> {
750    let kernel = to_batch_kernel(kernel)?;
751    let (prices, candles) = match data {
752        MaData::Slice(s) => (s, None),
753        MaData::Candles { candles, source } => (source_type(candles, source), Some(candles)),
754    };
755
756    #[inline]
757    fn get_f64(
758        params: Option<&HashMap<String, f64>>,
759        indicator: &'static str,
760        key: &'static str,
761    ) -> Result<Option<f64>, MaBatchDispatchError> {
762        match params.and_then(|m| m.get(key).copied()) {
763            None => Ok(None),
764            Some(v) if v.is_finite() => Ok(Some(v)),
765            Some(v) => Err(MaBatchDispatchError::InvalidParam {
766                indicator,
767                key,
768                value: v,
769                reason: "expected finite number",
770            }),
771        }
772    }
773
774    #[inline]
775    fn get_usize(
776        params: Option<&HashMap<String, f64>>,
777        indicator: &'static str,
778        key: &'static str,
779    ) -> Result<Option<usize>, MaBatchDispatchError> {
780        let Some(v) = get_f64(params, indicator, key)? else {
781            return Ok(None);
782        };
783        if v < 0.0 {
784            return Err(MaBatchDispatchError::InvalidParam {
785                indicator,
786                key,
787                value: v,
788                reason: "expected >= 0",
789            });
790        }
791        let r = v.round();
792        if (v - r).abs() > 1e-9 {
793            return Err(MaBatchDispatchError::InvalidParam {
794                indicator,
795                key,
796                value: v,
797                reason: "expected integer",
798            });
799        }
800        if r > (usize::MAX as f64) {
801            return Err(MaBatchDispatchError::InvalidParam {
802                indicator,
803                key,
804                value: v,
805                reason: "too large for usize",
806            });
807        }
808        Ok(Some(r as usize))
809    }
810
811    #[inline]
812    fn get_u32(
813        params: Option<&HashMap<String, f64>>,
814        indicator: &'static str,
815        key: &'static str,
816    ) -> Result<Option<u32>, MaBatchDispatchError> {
817        let Some(v) = get_usize(params, indicator, key)? else {
818            return Ok(None);
819        };
820        if v > (u32::MAX as usize) {
821            return Err(MaBatchDispatchError::InvalidParam {
822                indicator,
823                key,
824                value: v as f64,
825                reason: "too large for u32",
826            });
827        }
828        Ok(Some(v as u32))
829    }
830
831    match ma_type.to_ascii_lowercase().as_str() {
832        "sma" => {
833            let sweep = super::sma::SmaBatchRange {
834                period: period_range,
835            };
836            let out = super::sma::sma_batch_with_kernel(prices, &sweep, kernel)?;
837            Ok(MaBatchOutput {
838                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
839                values: out.values,
840                rows: out.rows,
841                cols: out.cols,
842            })
843        }
844        "ema" => {
845            let sweep = super::ema::EmaBatchRange {
846                period: period_range,
847            };
848            let out = super::ema::ema_batch_with_kernel(prices, &sweep, kernel)?;
849            Ok(MaBatchOutput {
850                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
851                values: out.values,
852                rows: out.rows,
853                cols: out.cols,
854            })
855        }
856        "dema" => {
857            let sweep = super::dema::DemaBatchRange {
858                period: period_range,
859            };
860            let out = super::dema::dema_batch_with_kernel(prices, &sweep, kernel)?;
861            Ok(MaBatchOutput {
862                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
863                values: out.values,
864                rows: out.rows,
865                cols: out.cols,
866            })
867        }
868        "tema" => {
869            let sweep = super::tema::TemaBatchRange {
870                period: period_range,
871            };
872            let out = super::tema::tema_batch_with_kernel(prices, &sweep, kernel)?;
873            Ok(MaBatchOutput {
874                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
875                values: out.values,
876                rows: out.rows,
877                cols: out.cols,
878            })
879        }
880        "smma" => {
881            let sweep = super::smma::SmmaBatchRange {
882                period: period_range,
883            };
884            let out = super::smma::smma_batch_with_kernel(prices, &sweep, kernel)?;
885            Ok(MaBatchOutput {
886                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
887                values: out.values,
888                rows: out.rows,
889                cols: out.cols,
890            })
891        }
892        "zlema" => {
893            let sweep = super::zlema::ZlemaBatchRange {
894                period: period_range,
895            };
896            let out = super::zlema::zlema_batch_with_kernel(prices, &sweep, kernel)?;
897            Ok(MaBatchOutput {
898                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
899                values: out.values,
900                rows: out.rows,
901                cols: out.cols,
902            })
903        }
904        "wma" => {
905            let sweep = super::wma::WmaBatchRange {
906                period: period_range,
907            };
908            let out = super::wma::wma_with_kernel_batch(prices, &sweep, kernel)?;
909            Ok(MaBatchOutput {
910                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
911                values: out.values,
912                rows: out.rows,
913                cols: out.cols,
914            })
915        }
916        "alma" => {
917            let mut sweep = super::alma::AlmaBatchRange::default();
918            sweep.period = period_range;
919            if let Some(v) = get_f64(params, "alma", "offset")? {
920                sweep.offset = (v, v, 0.0);
921            }
922            if let Some(v) = get_f64(params, "alma", "sigma")? {
923                sweep.sigma = (v, v, 0.0);
924            }
925            let out = super::alma::alma_batch_with_kernel(prices, &sweep, kernel)?;
926            Ok(MaBatchOutput {
927                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
928                values: out.values,
929                rows: out.rows,
930                cols: out.cols,
931            })
932        }
933        "cwma" => {
934            let sweep = super::cwma::CwmaBatchRange {
935                period: period_range,
936            };
937            let out = super::cwma::cwma_batch_with_kernel(prices, &sweep, kernel)?;
938            Ok(MaBatchOutput {
939                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
940                values: out.values,
941                rows: out.rows,
942                cols: out.cols,
943            })
944        }
945        "corrected_moving_average" | "cma" => {
946            let sweep = super::corrected_moving_average::CorrectedMovingAverageBatchRange {
947                period: period_range,
948            };
949            let out = super::corrected_moving_average::corrected_moving_average_batch_with_kernel(
950                prices, &sweep, kernel,
951            )?;
952            Ok(MaBatchOutput {
953                periods: map_periods(&out.combos, |p| p.period.unwrap_or(35)),
954                values: out.values,
955                rows: out.rows,
956                cols: out.cols,
957            })
958        }
959        "cora_wave" => {
960            let mut sweep = crate::indicators::cora_wave::CoraWaveBatchRange {
961                period: period_range,
962                r_multi: (2.0, 2.0, 0.0),
963                smooth: true,
964            };
965            if let Some(v) = get_f64(params, "cora_wave", "r_multi")? {
966                if v < 0.0 {
967                    return Err(MaBatchDispatchError::InvalidParam {
968                        indicator: "cora_wave",
969                        key: "r_multi",
970                        value: v,
971                        reason: "expected >= 0",
972                    }
973                    .into());
974                }
975                sweep.r_multi = (v, v, 0.0);
976            }
977            if let Some(v) = get_usize(params, "cora_wave", "smooth")? {
978                sweep.smooth = match v {
979                    0 => false,
980                    1 => true,
981                    other => {
982                        return Err(MaBatchDispatchError::InvalidParam {
983                            indicator: "cora_wave",
984                            key: "smooth",
985                            value: other as f64,
986                            reason: "expected 0 or 1",
987                        }
988                        .into());
989                    }
990                };
991            }
992            let out =
993                crate::indicators::cora_wave::cora_wave_batch_with_kernel(prices, &sweep, kernel)?;
994            Ok(MaBatchOutput {
995                periods: map_periods(&out.combos, |p| p.period.unwrap_or(20)),
996                values: out.values,
997                rows: out.rows,
998                cols: out.cols,
999            })
1000        }
1001        "edcf" => {
1002            let sweep = super::edcf::EdcfBatchRange {
1003                period: period_range,
1004            };
1005            let out = super::edcf::edcf_batch_with_kernel(prices, &sweep, kernel)?;
1006            Ok(MaBatchOutput {
1007                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1008                values: out.values,
1009                rows: out.rows,
1010                cols: out.cols,
1011            })
1012        }
1013        "fwma" => {
1014            let sweep = super::fwma::FwmaBatchRange {
1015                period: period_range,
1016            };
1017            let out = super::fwma::fwma_batch_with_kernel(prices, &sweep, kernel)?;
1018            Ok(MaBatchOutput {
1019                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1020                values: out.values,
1021                rows: out.rows,
1022                cols: out.cols,
1023            })
1024        }
1025        "gaussian" => {
1026            let mut sweep = super::gaussian::GaussianBatchRange::default();
1027            sweep.period = period_range;
1028            if let Some(v) = get_usize(params, "gaussian", "poles")? {
1029                sweep.poles = (v, v, 0);
1030            }
1031            let out = super::gaussian::gaussian_batch_with_kernel(prices, &sweep, kernel)?;
1032            Ok(MaBatchOutput {
1033                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1034                values: out.values,
1035                rows: out.rows,
1036                cols: out.cols,
1037            })
1038        }
1039        "highpass" => {
1040            let sweep = super::highpass::HighPassBatchRange {
1041                period: period_range,
1042            };
1043            let out = super::highpass::highpass_batch_with_kernel(prices, &sweep, kernel)?;
1044            Ok(MaBatchOutput {
1045                periods: map_periods(&out.combos, |p| p.period.unwrap_or(48)),
1046                values: out.values,
1047                rows: out.rows,
1048                cols: out.cols,
1049            })
1050        }
1051        "highpass2" | "highpass_2_pole" => {
1052            let mut sweep = super::highpass_2_pole::HighPass2BatchRange::default();
1053            sweep.period = period_range;
1054            if let Some(v) = get_f64(params, "highpass_2_pole", "k")? {
1055                sweep.k = (v, v, 0.0);
1056            }
1057            let out =
1058                super::highpass_2_pole::highpass_2_pole_batch_with_kernel(prices, &sweep, kernel)?;
1059            Ok(MaBatchOutput {
1060                periods: map_periods(&out.combos, |p| p.period.unwrap_or(48)),
1061                values: out.values,
1062                rows: out.rows,
1063                cols: out.cols,
1064            })
1065        }
1066        "hma" => {
1067            let sweep = super::hma::HmaBatchRange {
1068                period: period_range,
1069            };
1070            let out = super::hma::hma_batch_with_kernel(prices, &sweep, kernel)?;
1071            Ok(MaBatchOutput {
1072                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1073                values: out.values,
1074                rows: out.rows,
1075                cols: out.cols,
1076            })
1077        }
1078        "jma" => {
1079            let mut sweep = super::jma::JmaBatchRange::default();
1080            sweep.period = period_range;
1081            if let Some(v) = get_f64(params, "jma", "phase")? {
1082                sweep.phase = (v, v, 0.0);
1083            }
1084            if let Some(v) = get_u32(params, "jma", "power")? {
1085                sweep.power = (v, v, 0);
1086            }
1087            let out = super::jma::jma_batch_with_kernel(prices, &sweep, kernel)?;
1088            Ok(MaBatchOutput {
1089                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1090                values: out.values,
1091                rows: out.rows,
1092                cols: out.cols,
1093            })
1094        }
1095        "jsa" => {
1096            let sweep = super::jsa::JsaBatchRange {
1097                period: period_range,
1098            };
1099            let out = super::jsa::jsa_batch_with_kernel(prices, &sweep, kernel)?;
1100            Ok(MaBatchOutput {
1101                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1102                values: out.values,
1103                rows: out.rows,
1104                cols: out.cols,
1105            })
1106        }
1107        "linreg" => {
1108            let sweep = super::linreg::LinRegBatchRange {
1109                period: period_range,
1110            };
1111            let out = super::linreg::linreg_batch_with_kernel(prices, &sweep, kernel)?;
1112            Ok(MaBatchOutput {
1113                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1114                values: out.values,
1115                rows: out.rows,
1116                cols: out.cols,
1117            })
1118        }
1119        "kama" => {
1120            let sweep = super::kama::KamaBatchRange {
1121                period: period_range,
1122            };
1123            let out = super::kama::kama_batch_with_kernel(prices, &sweep, kernel)?;
1124            Ok(MaBatchOutput {
1125                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1126                values: out.values,
1127                rows: out.rows,
1128                cols: out.cols,
1129            })
1130        }
1131        "ehlers_kama" => {
1132            let sweep = super::ehlers_kama::EhlersKamaBatchRange {
1133                period: period_range,
1134            };
1135            let out = super::ehlers_kama::ehlers_kama_batch_with_kernel(prices, &sweep, kernel)?;
1136            Ok(MaBatchOutput {
1137                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1138                values: out.values,
1139                rows: out.rows,
1140                cols: out.cols,
1141            })
1142        }
1143        "ehlers_itrend" => {
1144            let warmup = get_usize(params, "ehlers_itrend", "warmup_bars")?.unwrap_or(20);
1145            let sweep = super::ehlers_itrend::EhlersITrendBatchRange {
1146                warmup_bars: (warmup, warmup, 0),
1147                max_dc_period: period_range,
1148            };
1149            let out =
1150                super::ehlers_itrend::ehlers_itrend_batch_with_kernel(prices, &sweep, kernel)?;
1151            Ok(MaBatchOutput {
1152                periods: map_periods(&out.combos, |p| p.max_dc_period.unwrap_or(48)),
1153                values: out.values,
1154                rows: out.rows,
1155                cols: out.cols,
1156            })
1157        }
1158        "ehlers_ecema" => {
1159            let gain_limit = get_usize(params, "ehlers_ecema", "gain_limit")?.unwrap_or(50);
1160            let sweep = super::ehlers_ecema::EhlersEcemaBatchRange {
1161                length: period_range,
1162                gain_limit: (gain_limit, gain_limit, 0),
1163            };
1164            let out = super::ehlers_ecema::ehlers_ecema_batch_with_kernel(prices, &sweep, kernel)?;
1165            Ok(MaBatchOutput {
1166                periods: map_periods(&out.combos, |p| p.length.unwrap_or(20)),
1167                values: out.values,
1168                rows: out.rows,
1169                cols: out.cols,
1170            })
1171        }
1172        "ehma" => {
1173            let sweep = super::ehma::EhmaBatchRange {
1174                period: period_range,
1175            };
1176            let out = super::ehma::ehma_batch_with_kernel(prices, &sweep, kernel)?;
1177            Ok(MaBatchOutput {
1178                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1179                values: out.values,
1180                rows: out.rows,
1181                cols: out.cols,
1182            })
1183        }
1184        "nama" => {
1185            let sweep = super::nama::NamaBatchRange {
1186                period: period_range,
1187            };
1188            let out = super::nama::nama_batch_with_kernel(prices, &sweep, kernel)?;
1189            Ok(MaBatchOutput {
1190                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1191                values: out.values,
1192                rows: out.rows,
1193                cols: out.cols,
1194            })
1195        }
1196        "n_order_ema" => {
1197            let out = super::n_order_ema::n_order_ema_batch_with_kernel(
1198                prices,
1199                &super::n_order_ema::NOrderEmaBatchRange {
1200                    period: (
1201                        period_range.0 as f64,
1202                        period_range.1 as f64,
1203                        period_range.2 as f64,
1204                    ),
1205                    order: (1, 1, 0),
1206                },
1207                &super::n_order_ema::NOrderEmaParams {
1208                    period: None,
1209                    order: None,
1210                    ema_style: Some("ema".to_string()),
1211                    iir_style: Some("impulse_matched".to_string()),
1212                },
1213                kernel,
1214            )?;
1215            Ok(MaBatchOutput {
1216                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9.0).round() as usize),
1217                values: out.values,
1218                rows: out.rows,
1219                cols: out.cols,
1220            })
1221        }
1222        "nma" => {
1223            let sweep = super::nma::NmaBatchRange {
1224                period: period_range,
1225            };
1226            let out = super::nma::nma_batch_with_kernel(prices, &sweep, kernel)?;
1227            Ok(MaBatchOutput {
1228                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1229                values: out.values,
1230                rows: out.rows,
1231                cols: out.cols,
1232            })
1233        }
1234        "pwma" => {
1235            let sweep = super::pwma::PwmaBatchRange {
1236                period: period_range,
1237            };
1238            let out = super::pwma::pwma_batch_with_kernel(prices, &sweep, kernel)?;
1239            Ok(MaBatchOutput {
1240                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1241                values: out.values,
1242                rows: out.rows,
1243                cols: out.cols,
1244            })
1245        }
1246        "reflex" => {
1247            let sweep = super::reflex::ReflexBatchRange {
1248                period: period_range,
1249            };
1250            let out = super::reflex::reflex_batch_with_kernel(prices, &sweep, kernel)?;
1251            Ok(MaBatchOutput {
1252                periods: map_periods(&out.combos, |p| p.period.unwrap_or(48)),
1253                values: out.values,
1254                rows: out.rows,
1255                cols: out.cols,
1256            })
1257        }
1258        "sinwma" => {
1259            let sweep = super::sinwma::SinWmaBatchRange {
1260                period: period_range,
1261            };
1262            let out = super::sinwma::sinwma_batch_with_kernel(prices, &sweep, kernel)?;
1263            Ok(MaBatchOutput {
1264                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1265                values: out.values,
1266                rows: out.rows,
1267                cols: out.cols,
1268            })
1269        }
1270        "sqwma" => {
1271            let sweep = super::sqwma::SqwmaBatchRange {
1272                period: period_range,
1273            };
1274            let out = super::sqwma::sqwma_batch_with_kernel(prices, &sweep, kernel)?;
1275            Ok(MaBatchOutput {
1276                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1277                values: out.values,
1278                rows: out.rows,
1279                cols: out.cols,
1280            })
1281        }
1282        "srwma" => {
1283            let sweep = super::srwma::SrwmaBatchRange {
1284                period: period_range,
1285            };
1286            let out = super::srwma::srwma_batch_with_kernel(prices, &sweep, kernel)?;
1287            Ok(MaBatchOutput {
1288                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1289                values: out.values,
1290                rows: out.rows,
1291                cols: out.cols,
1292            })
1293        }
1294        "sgf" => {
1295            let poly_order = get_usize(params, "sgf", "poly_order")?.unwrap_or(2);
1296            let sweep = super::sgf::SgfBatchRange {
1297                period: period_range,
1298                poly_order: (poly_order, poly_order, 0),
1299            };
1300            let out = super::sgf::sgf_batch_with_kernel(prices, &sweep, kernel)?;
1301            Ok(MaBatchOutput {
1302                periods: map_periods(&out.combos, |p| p.period.unwrap_or(21)),
1303                values: out.values,
1304                rows: out.rows,
1305                cols: out.cols,
1306            })
1307        }
1308        "swma" => {
1309            let sweep = super::swma::SwmaBatchRange {
1310                period: period_range,
1311            };
1312            let out = super::swma::swma_batch_with_kernel(prices, &sweep, kernel)?;
1313            Ok(MaBatchOutput {
1314                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1315                values: out.values,
1316                rows: out.rows,
1317                cols: out.cols,
1318            })
1319        }
1320        "supersmoother" => {
1321            let sweep = super::supersmoother::SuperSmootherBatchRange {
1322                period: period_range,
1323            };
1324            let out =
1325                super::supersmoother::supersmoother_batch_with_kernel(prices, &sweep, kernel)?;
1326            Ok(MaBatchOutput {
1327                periods: map_periods(&out.combos, |p| p.period.unwrap_or(48)),
1328                values: out.values,
1329                rows: out.rows,
1330                cols: out.cols,
1331            })
1332        }
1333        "supersmoother_3_pole" => {
1334            let sweep = super::supersmoother_3_pole::SuperSmoother3PoleBatchRange {
1335                period: period_range,
1336            };
1337            let out = super::supersmoother_3_pole::supersmoother_3_pole_batch_with_kernel(
1338                prices, &sweep, kernel,
1339            )?;
1340            Ok(MaBatchOutput {
1341                periods: map_periods(&out.combos, |p| p.period.unwrap_or(48)),
1342                values: out.values,
1343                rows: out.rows,
1344                cols: out.cols,
1345            })
1346        }
1347        "tilson" => {
1348            let mut sweep = super::tilson::TilsonBatchRange::default();
1349            sweep.period = period_range;
1350            if let Some(v) = get_f64(params, "tilson", "volume_factor")? {
1351                sweep.volume_factor = (v, v, 0.0);
1352            }
1353            let out = super::tilson::tilson_batch_with_kernel(prices, &sweep, kernel)?;
1354            Ok(MaBatchOutput {
1355                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1356                values: out.values,
1357                rows: out.rows,
1358                cols: out.cols,
1359            })
1360        }
1361        "trendflex" => {
1362            let sweep = super::trendflex::TrendFlexBatchRange {
1363                period: period_range,
1364            };
1365            let out = super::trendflex::trendflex_batch_with_kernel(prices, &sweep, kernel)?;
1366            Ok(MaBatchOutput {
1367                periods: map_periods(&out.combos, |p| p.period.unwrap_or(48)),
1368                values: out.values,
1369                rows: out.rows,
1370                cols: out.cols,
1371            })
1372        }
1373        "corrected_moving_average" => {
1374            let sweep = super::corrected_moving_average::CorrectedMovingAverageBatchRange {
1375                period: period_range,
1376            };
1377            let out = super::corrected_moving_average::corrected_moving_average_batch_with_kernel(
1378                prices, &sweep, kernel,
1379            )?;
1380            Ok(MaBatchOutput {
1381                periods: map_periods(&out.combos, |p| p.period.unwrap_or(35)),
1382                values: out.values,
1383                rows: out.rows,
1384                cols: out.cols,
1385            })
1386        }
1387        "ema_deviation_corrected_t3" => {
1388            let sweep = super::ema_deviation_corrected_t3::EmaDeviationCorrectedT3BatchRange {
1389                period: period_range,
1390                hot: {
1391                    let v = get_f64(params, "ema_deviation_corrected_t3", "hot")?.unwrap_or(0.7);
1392                    (v, v, 0.0)
1393                },
1394                t3_mode: {
1395                    let v =
1396                        get_usize(params, "ema_deviation_corrected_t3", "t3_mode")?.unwrap_or(0);
1397                    (v, v, 0)
1398                },
1399            };
1400            let out =
1401                super::ema_deviation_corrected_t3::ema_deviation_corrected_t3_batch_with_kernel(
1402                    prices, &sweep, kernel,
1403                )?;
1404            let periods = map_periods(&out.combos, |p| p.period.unwrap_or(10));
1405            let rows = out.rows;
1406            let cols = out.cols;
1407            Ok(MaBatchOutput {
1408                periods,
1409                values: out.corrected,
1410                rows,
1411                cols,
1412            })
1413        }
1414        "wave_smoother" => {
1415            let sweep = super::wave_smoother::WaveSmootherBatchRange {
1416                period: period_range,
1417                phase: {
1418                    let v = get_f64(params, "wave_smoother", "phase")?.unwrap_or(70.0);
1419                    (v, v, 0.0)
1420                },
1421            };
1422            let out =
1423                super::wave_smoother::wave_smoother_batch_with_kernel(prices, &sweep, kernel)?;
1424            Ok(MaBatchOutput {
1425                periods: map_periods(&out.combos, |p| p.period.unwrap_or(20)),
1426                values: out.values,
1427                rows: out.rows,
1428                cols: out.cols,
1429            })
1430        }
1431        "trima" => {
1432            let sweep = super::trima::TrimaBatchRange {
1433                period: period_range,
1434            };
1435            let out = super::trima::trima_batch_with_kernel(prices, &sweep, kernel)?;
1436            Ok(MaBatchOutput {
1437                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1438                values: out.values,
1439                rows: out.rows,
1440                cols: out.cols,
1441            })
1442        }
1443        "wilders" => {
1444            let sweep = super::wilders::WildersBatchRange {
1445                period: period_range,
1446            };
1447            let out = super::wilders::wilders_batch_with_kernel(prices, &sweep, kernel)?;
1448            Ok(MaBatchOutput {
1449                periods: map_periods(&out.combos, |p| p.period.unwrap_or(14)),
1450                values: out.values,
1451                rows: out.rows,
1452                cols: out.cols,
1453            })
1454        }
1455        "vpwma" => {
1456            let sweep = super::vpwma::VpwmaBatchRange {
1457                period: period_range,
1458                power: {
1459                    let v = get_f64(params, "vpwma", "power")?.unwrap_or(0.382);
1460                    (v, v, 0.0)
1461                },
1462            };
1463            let out = super::vpwma::vpwma_batch_with_kernel(prices, &sweep, kernel)?;
1464            Ok(MaBatchOutput {
1465                periods: map_periods(&out.combos, |p| p.period.unwrap_or(14)),
1466                values: out.values,
1467                rows: out.rows,
1468                cols: out.cols,
1469            })
1470        }
1471        "vwma" => {
1472            let candles =
1473                candles.ok_or(MaBatchDispatchError::RequiresCandles { indicator: "vwma" })?;
1474            let sweep = super::vwma::VwmaBatchRange {
1475                period: period_range,
1476            };
1477            let out = super::vwma::vwma_batch_with_kernel(prices, &candles.volume, &sweep, kernel)?;
1478            Ok(MaBatchOutput {
1479                periods: map_periods(&out.combos, |p| p.period.unwrap_or(20)),
1480                values: out.values,
1481                rows: out.rows,
1482                cols: out.cols,
1483            })
1484        }
1485        "elastic_volume_weighted_moving_average" => {
1486            let candles = candles.ok_or(MaBatchDispatchError::RequiresCandles {
1487                indicator: "elastic_volume_weighted_moving_average",
1488            })?;
1489            let mut sweep =
1490                super::elastic_volume_weighted_moving_average::ElasticVolumeWeightedMovingAverageBatchRange::default();
1491            sweep.length = period_range;
1492            if let Some(v) = get_f64(
1493                params,
1494                "elastic_volume_weighted_moving_average",
1495                "absolute_volume_millions",
1496            )? {
1497                sweep.absolute_volume_millions = Some(v);
1498            }
1499            if let Some(v) = get_usize(params, "elastic_volume_weighted_moving_average", "length")?
1500            {
1501                sweep.length = (v, v, 0);
1502            }
1503            if let Some(v) = get_usize(
1504                params,
1505                "elastic_volume_weighted_moving_average",
1506                "use_volume_sum",
1507            )? {
1508                sweep.use_volume_sum = Some(match v {
1509                    0 => false,
1510                    1 => true,
1511                    other => {
1512                        return Err(MaBatchDispatchError::InvalidParam {
1513                            indicator: "elastic_volume_weighted_moving_average",
1514                            key: "use_volume_sum",
1515                            value: other as f64,
1516                            reason: "expected 0 or 1",
1517                        }
1518                        .into());
1519                    }
1520                });
1521            } else {
1522                sweep.use_volume_sum = Some(true);
1523            }
1524            let out = super::elastic_volume_weighted_moving_average::elastic_volume_weighted_moving_average_batch_with_kernel(
1525                prices,
1526                &candles.volume,
1527                &sweep,
1528                kernel,
1529            )?;
1530            Ok(MaBatchOutput {
1531                periods: map_periods(&out.combos, |p| p.length.unwrap_or(30)),
1532                values: out.values,
1533                rows: out.rows,
1534                cols: out.cols,
1535            })
1536        }
1537        "tradjema" => {
1538            let candles = candles.ok_or(MaBatchDispatchError::RequiresCandles {
1539                indicator: "tradjema",
1540            })?;
1541            let mut sweep = super::tradjema::TradjemaBatchRange::default();
1542            sweep.length = period_range;
1543            if let Some(v) = get_f64(params, "tradjema", "mult")? {
1544                sweep.mult = (v, v, 0.0);
1545            }
1546            let out = super::tradjema::tradjema_batch_with_kernel(
1547                &candles.high,
1548                &candles.low,
1549                &candles.close,
1550                &sweep,
1551                kernel,
1552            )?;
1553            Ok(MaBatchOutput {
1554                periods: map_periods(&out.combos, |p| p.length.unwrap_or(40)),
1555                values: out.values,
1556                rows: out.rows,
1557                cols: out.cols,
1558            })
1559        }
1560        "uma" => {
1561            let mut sweep = super::uma::UmaBatchRange::default();
1562            sweep.max_length = period_range;
1563            if let Some(v) = get_f64(params, "uma", "accelerator")? {
1564                sweep.accelerator = (v, v, 0.0);
1565            }
1566            if let Some(v) = get_usize(params, "uma", "min_length")? {
1567                sweep.min_length = (v, v, 0);
1568            }
1569            if let Some(v) = get_usize(params, "uma", "max_length")? {
1570                sweep.max_length = (v, v, 0);
1571            }
1572            if let Some(v) = get_usize(params, "uma", "smooth_length")? {
1573                sweep.smooth_length = (v, v, 0);
1574            }
1575            let volumes = candles.map(|c| c.volume.as_slice());
1576            let out = super::uma::uma_batch_with_kernel(prices, volumes, &sweep, kernel)?;
1577            Ok(MaBatchOutput {
1578                periods: map_periods(&out.combos, |p| p.max_length.unwrap_or(50)),
1579                values: out.values,
1580                rows: out.rows,
1581                cols: out.cols,
1582            })
1583        }
1584        "volume_adjusted_ma" => {
1585            let candles = candles.ok_or(MaBatchDispatchError::RequiresCandles {
1586                indicator: "volume_adjusted_ma",
1587            })?;
1588            let mut sweep = super::volume_adjusted_ma::VolumeAdjustedMaBatchRange::default();
1589            sweep.length = period_range;
1590            if let Some(v) = get_f64(params, "volume_adjusted_ma", "vi_factor")? {
1591                sweep.vi_factor = (v, v, 0.0);
1592            }
1593            if let Some(v) = get_usize(params, "volume_adjusted_ma", "sample_period")? {
1594                sweep.sample_period = (v, v, 0);
1595            }
1596            if let Some(v) = get_usize(params, "volume_adjusted_ma", "strict")? {
1597                sweep.strict = Some(match v {
1598                    0 => false,
1599                    1 => true,
1600                    other => {
1601                        return Err(MaBatchDispatchError::InvalidParam {
1602                            indicator: "volume_adjusted_ma",
1603                            key: "strict",
1604                            value: other as f64,
1605                            reason: "expected 0 or 1",
1606                        }
1607                        .into());
1608                    }
1609                });
1610            }
1611            let out = super::volume_adjusted_ma::VolumeAdjustedMa_batch_with_kernel(
1612                prices,
1613                &candles.volume,
1614                &sweep,
1615                kernel,
1616            )?;
1617            Ok(MaBatchOutput {
1618                periods: map_periods(&out.combos, |p| p.length.unwrap_or(13)),
1619                values: out.values,
1620                rows: out.rows,
1621                cols: out.cols,
1622            })
1623        }
1624        "hwma" => {
1625            let mut sweep = super::hwma::HwmaBatchRange::default();
1626            if let Some(v) = get_f64(params, "hwma", "na")? {
1627                sweep.na = (v, v, 0.0);
1628            }
1629            if let Some(v) = get_f64(params, "hwma", "nb")? {
1630                sweep.nb = (v, v, 0.0);
1631            }
1632            if let Some(v) = get_f64(params, "hwma", "nc")? {
1633                sweep.nc = (v, v, 0.0);
1634            }
1635            let out = super::hwma::hwma_batch_with_kernel(prices, &sweep, kernel)?;
1636            Ok(MaBatchOutput {
1637                periods: (1..=out.rows).collect(),
1638                values: out.values,
1639                rows: out.rows,
1640                cols: out.cols,
1641            })
1642        }
1643        "mama" => {
1644            let mut sweep = super::mama::MamaBatchRange::default();
1645            if let Some(v) = get_f64(params, "mama", "fast_limit")? {
1646                sweep.fast_limit = (v, v, 0.0);
1647            } else {
1648                if let Some(v) = get_f64(params, "mama", "fast_limit_start")? {
1649                    sweep.fast_limit.0 = v;
1650                }
1651                if let Some(v) = get_f64(params, "mama", "fast_limit_end")? {
1652                    sweep.fast_limit.1 = v;
1653                }
1654                if let Some(v) = get_f64(params, "mama", "fast_limit_step")? {
1655                    sweep.fast_limit.2 = v;
1656                }
1657            }
1658            if let Some(v) = get_f64(params, "mama", "slow_limit")? {
1659                sweep.slow_limit = (v, v, 0.0);
1660            } else {
1661                if let Some(v) = get_f64(params, "mama", "slow_limit_start")? {
1662                    sweep.slow_limit.0 = v;
1663                }
1664                if let Some(v) = get_f64(params, "mama", "slow_limit_end")? {
1665                    sweep.slow_limit.1 = v;
1666                }
1667                if let Some(v) = get_f64(params, "mama", "slow_limit_step")? {
1668                    sweep.slow_limit.2 = v;
1669                }
1670            }
1671            let out = super::mama::mama_batch_with_kernel(prices, &sweep, kernel)?;
1672            Ok(MaBatchOutput {
1673                periods: (1..=out.rows).collect(),
1674                values: out.mama_values,
1675                rows: out.rows,
1676                cols: out.cols,
1677            })
1678        }
1679        "ehlers_pma" => {
1680            let periods = expand_period_axis(period_range)?;
1681            let rows = periods.len();
1682            let input = super::ehlers_pma::EhlersPmaInput::from_slice(
1683                prices,
1684                super::ehlers_pma::EhlersPmaParams::default(),
1685            );
1686            let out = super::ehlers_pma::ehlers_pma_with_kernel(&input, kernel)?;
1687            let cols = out.predict.len();
1688            let mut values = Vec::with_capacity(rows.saturating_mul(cols));
1689            for _ in 0..rows {
1690                values.extend_from_slice(&out.predict);
1691            }
1692            Ok(MaBatchOutput {
1693                periods,
1694                values,
1695                rows,
1696                cols,
1697            })
1698        }
1699        "ehlers_undersampled_double_moving_average" => {
1700            let mut sweep =
1701                super::ehlers_undersampled_double_moving_average::EhlersUndersampledDoubleMovingAverageBatchRange::default();
1702            if let Some(v) = get_usize(
1703                params,
1704                "ehlers_undersampled_double_moving_average",
1705                "fast_length",
1706            )? {
1707                sweep.fast_length = (v, v, 0);
1708            } else {
1709                if let Some(v) = get_usize(
1710                    params,
1711                    "ehlers_undersampled_double_moving_average",
1712                    "fast_length_start",
1713                )? {
1714                    sweep.fast_length.0 = v;
1715                }
1716                if let Some(v) = get_usize(
1717                    params,
1718                    "ehlers_undersampled_double_moving_average",
1719                    "fast_length_end",
1720                )? {
1721                    sweep.fast_length.1 = v;
1722                }
1723                if let Some(v) = get_usize(
1724                    params,
1725                    "ehlers_undersampled_double_moving_average",
1726                    "fast_length_step",
1727                )? {
1728                    sweep.fast_length.2 = v;
1729                }
1730            }
1731            if let Some(v) = get_usize(
1732                params,
1733                "ehlers_undersampled_double_moving_average",
1734                "slow_length",
1735            )? {
1736                sweep.slow_length = (v, v, 0);
1737            } else {
1738                if let Some(v) = get_usize(
1739                    params,
1740                    "ehlers_undersampled_double_moving_average",
1741                    "slow_length_start",
1742                )? {
1743                    sweep.slow_length.0 = v;
1744                }
1745                if let Some(v) = get_usize(
1746                    params,
1747                    "ehlers_undersampled_double_moving_average",
1748                    "slow_length_end",
1749                )? {
1750                    sweep.slow_length.1 = v;
1751                }
1752                if let Some(v) = get_usize(
1753                    params,
1754                    "ehlers_undersampled_double_moving_average",
1755                    "slow_length_step",
1756                )? {
1757                    sweep.slow_length.2 = v;
1758                }
1759            }
1760            if let Some(v) = get_usize(
1761                params,
1762                "ehlers_undersampled_double_moving_average",
1763                "sample_length",
1764            )? {
1765                sweep.sample_length = (v, v, 0);
1766            } else {
1767                if let Some(v) = get_usize(
1768                    params,
1769                    "ehlers_undersampled_double_moving_average",
1770                    "sample_length_start",
1771                )? {
1772                    sweep.sample_length.0 = v;
1773                }
1774                if let Some(v) = get_usize(
1775                    params,
1776                    "ehlers_undersampled_double_moving_average",
1777                    "sample_length_end",
1778                )? {
1779                    sweep.sample_length.1 = v;
1780                }
1781                if let Some(v) = get_usize(
1782                    params,
1783                    "ehlers_undersampled_double_moving_average",
1784                    "sample_length_step",
1785                )? {
1786                    sweep.sample_length.2 = v;
1787                }
1788            }
1789            let out =
1790                super::ehlers_undersampled_double_moving_average::ehlers_undersampled_double_moving_average_batch_with_kernel(
1791                    prices, &sweep, kernel,
1792                )?;
1793            Ok(MaBatchOutput {
1794                periods: (1..=out.rows).collect(),
1795                values: out.fast_values,
1796                rows: out.rows,
1797                cols: out.cols,
1798            })
1799        }
1800        "mwdx" => {
1801            let mut sweep = super::mwdx::MwdxBatchRange::default();
1802            if let Some(v) = get_f64(params, "mwdx", "factor")? {
1803                sweep.factor = (v, v, 0.0);
1804            } else {
1805                let fac_start = 2.0 / (period_range.0 as f64 + 1.0);
1806                let fac_end = 2.0 / (period_range.1 as f64 + 1.0);
1807                let next_period = if period_range.2 == 0 || period_range.0 == period_range.1 {
1808                    period_range.0
1809                } else if period_range.0 < period_range.1 {
1810                    period_range.0.saturating_add(period_range.2)
1811                } else {
1812                    period_range.0.saturating_sub(period_range.2)
1813                };
1814                let fac_next = 2.0 / (next_period as f64 + 1.0);
1815                let fac_step = (fac_next - fac_start).abs();
1816                sweep.factor = (fac_start, fac_end, fac_step);
1817            }
1818            let out = super::mwdx::mwdx_batch_with_kernel(prices, &sweep, kernel)?;
1819            Ok(MaBatchOutput {
1820                periods: map_periods(&out.combos, |p| {
1821                    let f = p.factor.unwrap_or(0.2);
1822                    if f > 0.0 {
1823                        ((2.0 / f) - 1.0).round().max(1.0) as usize
1824                    } else {
1825                        1
1826                    }
1827                }),
1828                values: out.values,
1829                rows: out.rows,
1830                cols: out.cols,
1831            })
1832        }
1833        "vwap" => {
1834            let candles =
1835                candles.ok_or(MaBatchDispatchError::RequiresCandles { indicator: "vwap" })?;
1836            let sweep = super::vwap::VwapBatchRange {
1837                anchor: ("1d".to_string(), "1d".to_string(), 0),
1838            };
1839            let out = super::vwap::vwap_batch_with_kernel(
1840                &candles.timestamp,
1841                &candles.volume,
1842                prices,
1843                &sweep,
1844                kernel,
1845            )?;
1846            let periods = out
1847                .combos
1848                .iter()
1849                .enumerate()
1850                .map(|(i, p)| {
1851                    p.anchor
1852                        .as_deref()
1853                        .and_then(|a| super::vwap::parse_anchor(a).ok().map(|(n, _)| n as usize))
1854                        .unwrap_or(i + 1)
1855                })
1856                .collect();
1857            Ok(MaBatchOutput {
1858                periods,
1859                values: out.values,
1860                rows: out.rows,
1861                cols: out.cols,
1862            })
1863        }
1864        "dma" => {
1865            let ema_length = get_usize(params, "dma", "ema_length")?.unwrap_or(20);
1866            let ema_gain_limit = get_usize(params, "dma", "ema_gain_limit")?.unwrap_or(50);
1867            let sweep = super::dma::DmaBatchRange {
1868                hull_length: period_range,
1869                ema_length: (ema_length, ema_length, 0),
1870                ema_gain_limit: (ema_gain_limit, ema_gain_limit, 0),
1871                hull_ma_type: "WMA".to_string(),
1872            };
1873            let out = super::dma::dma_batch_with_kernel(prices, &sweep, kernel)?;
1874            Ok(MaBatchOutput {
1875                periods: map_periods(&out.combos, |p| p.hull_length.unwrap_or(7)),
1876                values: out.values,
1877                rows: out.rows,
1878                cols: out.cols,
1879            })
1880        }
1881        "epma" => {
1882            let offset = get_usize(params, "epma", "offset")?.unwrap_or(4);
1883            let sweep = super::epma::EpmaBatchRange {
1884                period: period_range,
1885                offset: (offset, offset, 0),
1886            };
1887            let out = super::epma::epma_batch_with_kernel(prices, &sweep, kernel)?;
1888            Ok(MaBatchOutput {
1889                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1890                values: out.values,
1891                rows: out.rows,
1892                cols: out.cols,
1893            })
1894        }
1895        "sama" => {
1896            let mut sweep = super::sama::SamaBatchRange::default();
1897            sweep.length = period_range;
1898            if let Some(v) = get_usize(params, "sama", "maj_length")? {
1899                sweep.maj_length = (v, v, 0);
1900            }
1901            if let Some(v) = get_usize(params, "sama", "min_length")? {
1902                sweep.min_length = (v, v, 0);
1903            }
1904            let out = super::sama::sama_batch_with_kernel(prices, &sweep, kernel)?;
1905            Ok(MaBatchOutput {
1906                periods: map_periods(&out.combos, |p| p.length.unwrap_or(10)),
1907                values: out.values,
1908                rows: out.rows,
1909                cols: out.cols,
1910            })
1911        }
1912        "volatility_adjusted_ma" | "vama" => {
1913            let vol_period = get_usize(params, "vama", "vol_period")?.unwrap_or(51);
1914            let sweep = super::volatility_adjusted_ma::VamaBatchRange {
1915                base_period: period_range,
1916                vol_period: (vol_period, vol_period, 0),
1917            };
1918            let out =
1919                super::volatility_adjusted_ma::vama_batch_with_kernel(prices, &sweep, kernel)?;
1920            Ok(MaBatchOutput {
1921                periods: map_periods(&out.combos, |p| p.base_period.unwrap_or(10)),
1922                values: out.values,
1923                rows: out.rows,
1924                cols: out.cols,
1925            })
1926        }
1927        "maaq" => {
1928            let mut sweep = super::maaq::MaaqBatchRange::default();
1929            sweep.period = period_range;
1930            if let Some(v) = get_usize(params, "maaq", "fast_period")? {
1931                sweep.fast_period = (v, v, 0);
1932            }
1933            if let Some(v) = get_usize(params, "maaq", "slow_period")? {
1934                sweep.slow_period = (v, v, 0);
1935            }
1936            let out = super::maaq::maaq_batch_with_kernel(prices, &sweep, kernel)?;
1937            Ok(MaBatchOutput {
1938                periods: map_periods(&out.combos, |p| p.period.unwrap_or(9)),
1939                values: out.values,
1940                rows: out.rows,
1941                cols: out.cols,
1942            })
1943        }
1944        "frama" => {
1945            let sc = get_usize(params, "frama", "sc")?.unwrap_or(300);
1946            let fc = get_usize(params, "frama", "fc")?.unwrap_or(1);
1947            let (high, low, close) = match candles {
1948                Some(c) => (&c.high[..], &c.low[..], &c.close[..]),
1949                None => (prices, prices, prices),
1950            };
1951            let sweep = super::frama::FramaBatchRange {
1952                window: period_range,
1953                sc: (sc, sc, 0),
1954                fc: (fc, fc, 0),
1955            };
1956            let out = super::frama::frama_batch_with_kernel(high, low, close, &sweep, kernel)?;
1957            Ok(MaBatchOutput {
1958                periods: map_periods(&out.combos, |p| p.window.unwrap_or(10)),
1959                values: out.values,
1960                rows: out.rows,
1961                cols: out.cols,
1962            })
1963        }
1964        "buff_averages" => {
1965            let candles = candles.ok_or(MaBatchDispatchError::RequiresCandles {
1966                indicator: "buff_averages",
1967            })?;
1968            let mut sweep = super::buff_averages::BuffAveragesBatchRange::default();
1969            sweep.slow_period = period_range;
1970
1971            if let Some(v) = get_usize(params, "buff_averages", "fast_period")? {
1972                sweep.fast_period = (v, v, 0);
1973            }
1974            if let Some(v) = get_usize(params, "buff_averages", "slow_period")? {
1975                sweep.slow_period = (v, v, 0);
1976            }
1977            if let Some(v) = get_usize(params, "buff_averages", "fast_period_start")? {
1978                sweep.fast_period.0 = v;
1979            }
1980            if let Some(v) = get_usize(params, "buff_averages", "fast_period_end")? {
1981                sweep.fast_period.1 = v;
1982            }
1983            if let Some(v) = get_usize(params, "buff_averages", "fast_period_step")? {
1984                sweep.fast_period.2 = v;
1985            }
1986            if let Some(v) = get_usize(params, "buff_averages", "slow_period_start")? {
1987                sweep.slow_period.0 = v;
1988            }
1989            if let Some(v) = get_usize(params, "buff_averages", "slow_period_end")? {
1990                sweep.slow_period.1 = v;
1991            }
1992            if let Some(v) = get_usize(params, "buff_averages", "slow_period_step")? {
1993                sweep.slow_period.2 = v;
1994            }
1995
1996            let out = super::buff_averages::buff_averages_batch_with_kernel(
1997                prices,
1998                &candles.volume,
1999                &sweep,
2000                kernel,
2001            )?;
2002
2003            let all_fast_same = out
2004                .combos
2005                .first()
2006                .map(|c| out.combos.iter().all(|x| x.0 == c.0))
2007                .unwrap_or(true);
2008            let all_slow_same = out
2009                .combos
2010                .first()
2011                .map(|c| out.combos.iter().all(|x| x.1 == c.1))
2012                .unwrap_or(true);
2013            let periods = if all_fast_same {
2014                out.combos.iter().map(|c| c.1).collect()
2015            } else if all_slow_same {
2016                out.combos.iter().map(|c| c.0).collect()
2017            } else {
2018                (1..=out.rows).collect()
2019            };
2020
2021            Ok(MaBatchOutput {
2022                periods,
2023                values: out.fast,
2024                rows: out.rows,
2025                cols: out.cols,
2026            })
2027        }
2028        other => Err(MaBatchDispatchError::UnknownType {
2029            ma_type: other.to_string(),
2030        }
2031        .into()),
2032    }
2033}
2034
2035#[cfg(test)]
2036mod tests {
2037    use super::*;
2038    use crate::indicators::moving_averages::ma::ma_with_kernel;
2039    use crate::utilities::data_loader::Candles;
2040    use crate::utilities::enums::Kernel;
2041
2042    fn sample_prices(len: usize) -> Vec<f64> {
2043        (0..len)
2044            .map(|i| ((i as f64) * 0.1).sin() + (i as f64) * 0.001 + 100.0)
2045            .collect()
2046    }
2047
2048    fn sample_candles(len: usize) -> Candles {
2049        let timestamp: Vec<i64> = (0..len)
2050            .map(|i| 1_700_000_000_000_i64 + (i as i64) * 60_000)
2051            .collect();
2052        let close = sample_prices(len);
2053        let open: Vec<f64> = close.iter().map(|v| v - 0.1).collect();
2054        let high: Vec<f64> = close
2055            .iter()
2056            .enumerate()
2057            .map(|(i, v)| v + 0.35 + ((i as f64) * 0.01).sin().abs())
2058            .collect();
2059        let low: Vec<f64> = close
2060            .iter()
2061            .enumerate()
2062            .map(|(i, v)| v - 0.35 - ((i as f64) * 0.01).sin().abs())
2063            .collect();
2064        let volume: Vec<f64> = (0..len)
2065            .map(|i| 1000.0 + ((i % 31) as f64) * 7.0 + (i as f64) * 0.1)
2066            .collect();
2067        Candles::new(timestamp, open, high, low, close, volume)
2068    }
2069
2070    fn assert_series_eq(a: &[f64], b: &[f64], tol: f64) {
2071        assert_eq!(a.len(), b.len());
2072        for (i, (&av, &bv)) in a.iter().zip(b.iter()).enumerate() {
2073            if av.is_nan() && bv.is_nan() {
2074                continue;
2075            }
2076            let d = (av - bv).abs();
2077            assert!(
2078                d <= tol,
2079                "series mismatch at index {i}: left={av}, right={bv}, abs_diff={d}"
2080            );
2081        }
2082    }
2083
2084    fn assert_series_eq_ctx(a: &[f64], b: &[f64], tol: f64, ctx: &str) {
2085        assert_eq!(a.len(), b.len(), "length mismatch for {ctx}");
2086        for (i, (&av, &bv)) in a.iter().zip(b.iter()).enumerate() {
2087            if av.is_nan() && bv.is_nan() {
2088                continue;
2089            }
2090            let d = (av - bv).abs();
2091            assert!(
2092                d <= tol,
2093                "series mismatch for {ctx} at index {i}: left={av}, right={bv}, abs_diff={d}"
2094            );
2095        }
2096    }
2097
2098    #[test]
2099    fn period_based_batch_matches_single_direct_for_many_ids() {
2100        let prices = sample_prices(320);
2101        let candles = sample_candles(320);
2102        let period_range = (18, 22, 2);
2103        let expected_periods = vec![18, 20, 22];
2104
2105        let slice_cases = [
2106            "sma",
2107            "ema",
2108            "dema",
2109            "tema",
2110            "smma",
2111            "zlema",
2112            "wma",
2113            "alma",
2114            "cwma",
2115            "corrected_moving_average",
2116            "cora_wave",
2117            "edcf",
2118            "fwma",
2119            "gaussian",
2120            "highpass",
2121            "highpass_2_pole",
2122            "hma",
2123            "jma",
2124            "jsa",
2125            "linreg",
2126            "kama",
2127            "ehlers_kama",
2128            "ehlers_ecema",
2129            "ehma",
2130            "nama",
2131            "nma",
2132            "pwma",
2133            "reflex",
2134            "sinwma",
2135            "sqwma",
2136            "srwma",
2137            "sgf",
2138            "swma",
2139            "supersmoother",
2140            "supersmoother_3_pole",
2141            "tilson",
2142            "trendflex",
2143            "corrected_moving_average",
2144            "ema_deviation_corrected_t3",
2145            "wave_smoother",
2146            "trima",
2147            "wilders",
2148            "epma",
2149            "sama",
2150        ];
2151
2152        for ma_type in slice_cases {
2153            let batch =
2154                ma_batch_with_kernel(ma_type, MaData::Slice(&prices), period_range, Kernel::Auto)
2155                    .unwrap();
2156
2157            assert_eq!(batch.periods, expected_periods);
2158            assert_eq!(batch.rows, expected_periods.len());
2159            assert_eq!(batch.cols, prices.len());
2160
2161            for (row, period) in expected_periods.iter().copied().enumerate() {
2162                let direct =
2163                    ma_with_kernel(ma_type, MaData::Slice(&prices), period, Kernel::Auto).unwrap();
2164                let start = row * batch.cols;
2165                let end = start + batch.cols;
2166                let ctx = format!("{ma_type} slice period={period}");
2167                assert_series_eq_ctx(&batch.values[start..end], &direct, 1e-10, &ctx);
2168            }
2169        }
2170
2171        let candle_cases = ["vpwma", "vwma", "frama"];
2172        for ma_type in candle_cases {
2173            let batch = ma_batch_with_kernel(
2174                ma_type,
2175                MaData::Candles {
2176                    candles: &candles,
2177                    source: "close",
2178                },
2179                period_range,
2180                Kernel::Auto,
2181            )
2182            .unwrap();
2183
2184            assert_eq!(batch.periods, expected_periods);
2185            assert_eq!(batch.rows, expected_periods.len());
2186            assert_eq!(batch.cols, candles.close.len());
2187
2188            for (row, period) in expected_periods.iter().copied().enumerate() {
2189                let direct = ma_with_kernel(
2190                    ma_type,
2191                    MaData::Candles {
2192                        candles: &candles,
2193                        source: "close",
2194                    },
2195                    period,
2196                    Kernel::Auto,
2197                )
2198                .unwrap();
2199                let start = row * batch.cols;
2200                let end = start + batch.cols;
2201                let ctx = format!("{ma_type} candles period={period}");
2202                assert_series_eq_ctx(&batch.values[start..end], &direct, 1e-10, &ctx);
2203            }
2204        }
2205    }
2206
2207    #[test]
2208    fn mama_typed_output_selection_matches_direct() {
2209        let prices = sample_prices(256);
2210        let data = MaData::Slice(&prices);
2211        let params = [
2212            MaBatchParamKV {
2213                key: "fast_limit",
2214                value: MaBatchParamValue::Float(0.35),
2215            },
2216            MaBatchParamKV {
2217                key: "slow_limit",
2218                value: MaBatchParamValue::Float(0.06),
2219            },
2220            MaBatchParamKV {
2221                key: "output",
2222                value: MaBatchParamValue::EnumString("fama"),
2223            },
2224        ];
2225
2226        let got =
2227            ma_batch_with_kernel_and_typed_params("mama", data, (10, 10, 0), Kernel::Auto, &params)
2228                .unwrap();
2229
2230        let direct = crate::indicators::moving_averages::mama::mama_batch_with_kernel(
2231            &prices,
2232            &crate::indicators::moving_averages::mama::MamaBatchRange {
2233                fast_limit: (0.35, 0.35, 0.0),
2234                slow_limit: (0.06, 0.06, 0.0),
2235            },
2236            Kernel::Auto,
2237        )
2238        .unwrap();
2239
2240        assert_eq!(got.rows, direct.rows);
2241        assert_eq!(got.cols, direct.cols);
2242        assert_eq!(got.periods, (1..=direct.rows).collect::<Vec<_>>());
2243        assert_series_eq(&got.values, &direct.fama_values, 1e-12);
2244    }
2245
2246    #[test]
2247    fn ehlers_pma_typed_output_selection_matches_direct() {
2248        let prices = sample_prices(300);
2249        let data = MaData::Slice(&prices);
2250        let params = [MaBatchParamKV {
2251            key: "output",
2252            value: MaBatchParamValue::EnumString("trigger"),
2253        }];
2254
2255        let got = ma_batch_with_kernel_and_typed_params(
2256            "ehlers_pma",
2257            data,
2258            (8, 10, 1),
2259            Kernel::Auto,
2260            &params,
2261        )
2262        .unwrap();
2263
2264        let input = crate::indicators::moving_averages::ehlers_pma::EhlersPmaInput::from_slice(
2265            &prices,
2266            crate::indicators::moving_averages::ehlers_pma::EhlersPmaParams::default(),
2267        );
2268        let direct = crate::indicators::moving_averages::ehlers_pma::ehlers_pma_with_kernel(
2269            &input,
2270            Kernel::Auto,
2271        )
2272        .unwrap();
2273
2274        assert_eq!(got.rows, 3);
2275        assert_eq!(got.cols, prices.len());
2276        assert_eq!(got.periods, vec![8, 9, 10]);
2277        for row in 0..got.rows {
2278            let start = row * got.cols;
2279            let end = start + got.cols;
2280            assert_series_eq(&got.values[start..end], &direct.trigger, 1e-12);
2281        }
2282    }
2283
2284    #[test]
2285    fn invalid_output_selection_returns_error() {
2286        let prices = sample_prices(256);
2287        let data = MaData::Slice(&prices);
2288        let params = [MaBatchParamKV {
2289            key: "output",
2290            value: MaBatchParamValue::EnumString("bad_line"),
2291        }];
2292
2293        let err =
2294            ma_batch_with_kernel_and_typed_params("mama", data, (10, 10, 0), Kernel::Auto, &params)
2295                .unwrap_err()
2296                .to_string();
2297
2298        assert!(err.contains("expected 'mama' or 'fama'"));
2299    }
2300
2301    #[test]
2302    fn mama_numeric_path_defaults_to_primary_output() {
2303        let prices = sample_prices(256);
2304        let mut params = HashMap::new();
2305        params.insert("fast_limit".to_string(), 0.4);
2306        params.insert("slow_limit".to_string(), 0.07);
2307
2308        let got = ma_batch_with_kernel_and_params(
2309            "mama",
2310            MaData::Slice(&prices),
2311            (12, 12, 0),
2312            Kernel::Auto,
2313            Some(&params),
2314        )
2315        .unwrap();
2316
2317        let direct = crate::indicators::moving_averages::mama::mama_batch_with_kernel(
2318            &prices,
2319            &crate::indicators::moving_averages::mama::MamaBatchRange {
2320                fast_limit: (0.4, 0.4, 0.0),
2321                slow_limit: (0.07, 0.07, 0.0),
2322            },
2323            Kernel::Auto,
2324        )
2325        .unwrap();
2326
2327        assert_eq!(got.rows, direct.rows);
2328        assert_eq!(got.cols, direct.cols);
2329        assert_series_eq(&got.values, &direct.mama_values, 1e-12);
2330    }
2331
2332    #[test]
2333    fn ehlers_pma_numeric_path_defaults_and_descending_periods() {
2334        let prices = sample_prices(300);
2335        let got = ma_batch_with_kernel_and_params(
2336            "ehlers_pma",
2337            MaData::Slice(&prices),
2338            (10, 8, 1),
2339            Kernel::Auto,
2340            None,
2341        )
2342        .unwrap();
2343
2344        let input = crate::indicators::moving_averages::ehlers_pma::EhlersPmaInput::from_slice(
2345            &prices,
2346            crate::indicators::moving_averages::ehlers_pma::EhlersPmaParams::default(),
2347        );
2348        let direct = crate::indicators::moving_averages::ehlers_pma::ehlers_pma_with_kernel(
2349            &input,
2350            Kernel::Auto,
2351        )
2352        .unwrap();
2353
2354        assert_eq!(got.periods, vec![10, 9, 8]);
2355        assert_eq!(got.rows, 3);
2356        assert_eq!(got.cols, prices.len());
2357        for row in 0..got.rows {
2358            let start = row * got.cols;
2359            let end = start + got.cols;
2360            assert_series_eq(&got.values[start..end], &direct.predict, 1e-12);
2361        }
2362    }
2363
2364    #[test]
2365    fn hwma_typed_params_match_direct() {
2366        let prices = sample_prices(256);
2367        let params = [
2368            MaBatchParamKV {
2369                key: "na",
2370                value: MaBatchParamValue::Float(0.23),
2371            },
2372            MaBatchParamKV {
2373                key: "nb",
2374                value: MaBatchParamValue::Float(0.11),
2375            },
2376            MaBatchParamKV {
2377                key: "nc",
2378                value: MaBatchParamValue::Float(0.17),
2379            },
2380        ];
2381        let got = ma_batch_with_kernel_and_typed_params(
2382            "hwma",
2383            MaData::Slice(&prices),
2384            (10, 10, 0),
2385            Kernel::Auto,
2386            &params,
2387        )
2388        .unwrap();
2389        let direct = crate::indicators::moving_averages::hwma::hwma_batch_with_kernel(
2390            &prices,
2391            &crate::indicators::moving_averages::hwma::HwmaBatchRange {
2392                na: (0.23, 0.23, 0.0),
2393                nb: (0.11, 0.11, 0.0),
2394                nc: (0.17, 0.17, 0.0),
2395            },
2396            Kernel::Auto,
2397        )
2398        .unwrap();
2399        assert_eq!(got.rows, direct.rows);
2400        assert_eq!(got.cols, direct.cols);
2401        assert_series_eq(&got.values, &direct.values, 1e-12);
2402    }
2403
2404    #[test]
2405    fn mwdx_typed_factor_matches_direct() {
2406        let prices = sample_prices(256);
2407        let params = [MaBatchParamKV {
2408            key: "factor",
2409            value: MaBatchParamValue::Float(2.0 / 11.0),
2410        }];
2411        let got = ma_batch_with_kernel_and_typed_params(
2412            "mwdx",
2413            MaData::Slice(&prices),
2414            (10, 10, 0),
2415            Kernel::Auto,
2416            &params,
2417        )
2418        .unwrap();
2419        let direct = crate::indicators::moving_averages::mwdx::mwdx_batch_with_kernel(
2420            &prices,
2421            &crate::indicators::moving_averages::mwdx::MwdxBatchRange {
2422                factor: (2.0 / 11.0, 2.0 / 11.0, 0.0),
2423            },
2424            Kernel::Auto,
2425        )
2426        .unwrap();
2427        assert_eq!(got.rows, direct.rows);
2428        assert_eq!(got.cols, direct.cols);
2429        assert_series_eq(&got.values, &direct.values, 1e-12);
2430    }
2431
2432    #[test]
2433    fn uma_typed_params_match_direct() {
2434        let prices = sample_prices(256);
2435        let params = [
2436            MaBatchParamKV {
2437                key: "accelerator",
2438                value: MaBatchParamValue::Float(1.0),
2439            },
2440            MaBatchParamKV {
2441                key: "min_length",
2442                value: MaBatchParamValue::Int(5),
2443            },
2444            MaBatchParamKV {
2445                key: "max_length",
2446                value: MaBatchParamValue::Int(35),
2447            },
2448            MaBatchParamKV {
2449                key: "smooth_length",
2450                value: MaBatchParamValue::Int(4),
2451            },
2452        ];
2453        let got = ma_batch_with_kernel_and_typed_params(
2454            "uma",
2455            MaData::Slice(&prices),
2456            (35, 35, 0),
2457            Kernel::Auto,
2458            &params,
2459        )
2460        .unwrap();
2461        let direct = crate::indicators::moving_averages::uma::uma_batch_with_kernel(
2462            &prices,
2463            None,
2464            &crate::indicators::moving_averages::uma::UmaBatchRange {
2465                accelerator: (1.0, 1.0, 0.0),
2466                min_length: (5, 5, 0),
2467                max_length: (35, 35, 0),
2468                smooth_length: (4, 4, 0),
2469            },
2470            Kernel::Auto,
2471        )
2472        .unwrap();
2473        assert_eq!(got.rows, direct.rows);
2474        assert_eq!(got.cols, direct.cols);
2475        assert_series_eq(&got.values, &direct.values, 1e-12);
2476    }
2477
2478    #[test]
2479    fn tradjema_typed_params_match_direct() {
2480        let candles = sample_candles(300);
2481        let params = [MaBatchParamKV {
2482            key: "mult",
2483            value: MaBatchParamValue::Float(2.3),
2484        }];
2485        let got = ma_batch_with_kernel_and_typed_params(
2486            "tradjema",
2487            MaData::Candles {
2488                candles: &candles,
2489                source: "close",
2490            },
2491            (40, 40, 0),
2492            Kernel::Auto,
2493            &params,
2494        )
2495        .unwrap();
2496        let direct = crate::indicators::moving_averages::tradjema::tradjema_batch_with_kernel(
2497            &candles.high,
2498            &candles.low,
2499            &candles.close,
2500            &crate::indicators::moving_averages::tradjema::TradjemaBatchRange {
2501                length: (40, 40, 0),
2502                mult: (2.3, 2.3, 0.0),
2503            },
2504            Kernel::Auto,
2505        )
2506        .unwrap();
2507        assert_eq!(got.rows, direct.rows);
2508        assert_eq!(got.cols, direct.cols);
2509        assert_series_eq(&got.values, &direct.values, 1e-12);
2510    }
2511
2512    #[test]
2513    fn volume_adjusted_ma_typed_params_match_direct() {
2514        let candles = sample_candles(300);
2515        let params = [
2516            MaBatchParamKV {
2517                key: "vi_factor",
2518                value: MaBatchParamValue::Float(2.0),
2519            },
2520            MaBatchParamKV {
2521                key: "sample_period",
2522                value: MaBatchParamValue::Int(30),
2523            },
2524            MaBatchParamKV {
2525                key: "strict",
2526                value: MaBatchParamValue::Bool(true),
2527            },
2528        ];
2529        let got = ma_batch_with_kernel_and_typed_params(
2530            "volume_adjusted_ma",
2531            MaData::Candles {
2532                candles: &candles,
2533                source: "close",
2534            },
2535            (20, 20, 0),
2536            Kernel::Auto,
2537            &params,
2538        )
2539        .unwrap();
2540        let direct =
2541            crate::indicators::moving_averages::volume_adjusted_ma::VolumeAdjustedMa_batch_with_kernel(
2542                &candles.close,
2543                &candles.volume,
2544                &crate::indicators::moving_averages::volume_adjusted_ma::VolumeAdjustedMaBatchRange {
2545                    length: (20, 20, 0),
2546                    vi_factor: (2.0, 2.0, 0.0),
2547                    sample_period: (30, 30, 0),
2548                    strict: Some(true),
2549                },
2550                Kernel::Auto,
2551            )
2552            .unwrap();
2553        assert_eq!(got.rows, direct.rows);
2554        assert_eq!(got.cols, direct.cols);
2555        assert_series_eq(&got.values, &direct.values, 1e-12);
2556    }
2557
2558    #[test]
2559    fn vwap_typed_anchor_matches_direct() {
2560        let candles = sample_candles(300);
2561        let params = [MaBatchParamKV {
2562            key: "anchor",
2563            value: MaBatchParamValue::EnumString("1d"),
2564        }];
2565        let got = ma_batch_with_kernel_and_typed_params(
2566            "vwap",
2567            MaData::Candles {
2568                candles: &candles,
2569                source: "close",
2570            },
2571            (10, 10, 0),
2572            Kernel::Auto,
2573            &params,
2574        )
2575        .unwrap();
2576        let direct = crate::indicators::moving_averages::vwap::vwap_batch_with_kernel(
2577            &candles.timestamp,
2578            &candles.volume,
2579            &candles.close,
2580            &crate::indicators::moving_averages::vwap::VwapBatchRange {
2581                anchor: ("1d".to_string(), "1d".to_string(), 0),
2582            },
2583            Kernel::Auto,
2584        )
2585        .unwrap();
2586        assert_eq!(got.rows, direct.rows);
2587        assert_eq!(got.cols, direct.cols);
2588        assert_series_eq(&got.values, &direct.values, 1e-12);
2589    }
2590
2591    #[test]
2592    fn dma_typed_hull_ma_type_matches_direct() {
2593        let prices = sample_prices(256);
2594        let params = [
2595            MaBatchParamKV {
2596                key: "ema_length",
2597                value: MaBatchParamValue::Int(20),
2598            },
2599            MaBatchParamKV {
2600                key: "ema_gain_limit",
2601                value: MaBatchParamValue::Int(50),
2602            },
2603            MaBatchParamKV {
2604                key: "hull_ma_type",
2605                value: MaBatchParamValue::EnumString("EMA"),
2606            },
2607        ];
2608        let got = ma_batch_with_kernel_and_typed_params(
2609            "dma",
2610            MaData::Slice(&prices),
2611            (14, 14, 0),
2612            Kernel::Auto,
2613            &params,
2614        )
2615        .unwrap();
2616        let direct = crate::indicators::moving_averages::dma::dma_batch_with_kernel(
2617            &prices,
2618            &crate::indicators::moving_averages::dma::DmaBatchRange {
2619                hull_length: (14, 14, 0),
2620                ema_length: (20, 20, 0),
2621                ema_gain_limit: (50, 50, 0),
2622                hull_ma_type: "EMA".to_string(),
2623            },
2624            Kernel::Auto,
2625        )
2626        .unwrap();
2627        assert_eq!(got.rows, direct.rows);
2628        assert_eq!(got.cols, direct.cols);
2629        assert_series_eq(&got.values, &direct.values, 1e-12);
2630    }
2631
2632    #[test]
2633    fn ehlers_itrend_typed_params_match_direct() {
2634        let prices = sample_prices(320);
2635        let params = [MaBatchParamKV {
2636            key: "warmup_bars",
2637            value: MaBatchParamValue::Int(30),
2638        }];
2639        let got = ma_batch_with_kernel_and_typed_params(
2640            "ehlers_itrend",
2641            MaData::Slice(&prices),
2642            (48, 48, 0),
2643            Kernel::Auto,
2644            &params,
2645        )
2646        .unwrap();
2647        let direct =
2648            crate::indicators::moving_averages::ehlers_itrend::ehlers_itrend_batch_with_kernel(
2649                &prices,
2650                &crate::indicators::moving_averages::ehlers_itrend::EhlersITrendBatchRange {
2651                    warmup_bars: (30, 30, 0),
2652                    max_dc_period: (48, 48, 0),
2653                },
2654                Kernel::Auto,
2655            )
2656            .unwrap();
2657        assert_eq!(got.rows, direct.rows);
2658        assert_eq!(got.cols, direct.cols);
2659        assert_series_eq(&got.values, &direct.values, 1e-12);
2660    }
2661
2662    #[test]
2663    fn vama_typed_params_match_direct() {
2664        let prices = sample_prices(320);
2665        let params = [MaBatchParamKV {
2666            key: "vol_period",
2667            value: MaBatchParamValue::Int(51),
2668        }];
2669        let got = ma_batch_with_kernel_and_typed_params(
2670            "vama",
2671            MaData::Slice(&prices),
2672            (18, 22, 2),
2673            Kernel::Auto,
2674            &params,
2675        )
2676        .unwrap();
2677        let direct =
2678            crate::indicators::moving_averages::volatility_adjusted_ma::vama_batch_with_kernel(
2679                &prices,
2680                &crate::indicators::moving_averages::volatility_adjusted_ma::VamaBatchRange {
2681                    base_period: (18, 22, 2),
2682                    vol_period: (51, 51, 0),
2683                },
2684                Kernel::Auto,
2685            )
2686            .unwrap();
2687        assert_eq!(got.rows, direct.rows);
2688        assert_eq!(got.cols, direct.cols);
2689        assert_series_eq(&got.values, &direct.values, 1e-12);
2690    }
2691
2692    #[test]
2693    fn maaq_typed_params_match_direct() {
2694        let prices = sample_prices(320);
2695        let params = [
2696            MaBatchParamKV {
2697                key: "fast_period",
2698                value: MaBatchParamValue::Int(2),
2699            },
2700            MaBatchParamKV {
2701                key: "slow_period",
2702                value: MaBatchParamValue::Int(30),
2703            },
2704        ];
2705        let got = ma_batch_with_kernel_and_typed_params(
2706            "maaq",
2707            MaData::Slice(&prices),
2708            (18, 22, 2),
2709            Kernel::Auto,
2710            &params,
2711        )
2712        .unwrap();
2713        let direct = crate::indicators::moving_averages::maaq::maaq_batch_with_kernel(
2714            &prices,
2715            &crate::indicators::moving_averages::maaq::MaaqBatchRange {
2716                period: (18, 22, 2),
2717                fast_period: (2, 2, 0),
2718                slow_period: (30, 30, 0),
2719            },
2720            Kernel::Auto,
2721        )
2722        .unwrap();
2723        assert_eq!(got.rows, direct.rows);
2724        assert_eq!(got.cols, direct.cols);
2725        assert_series_eq(&got.values, &direct.values, 1e-12);
2726    }
2727
2728    #[test]
2729    fn tradjema_requires_candles_error() {
2730        let prices = sample_prices(256);
2731        let err = ma_batch_with_kernel_and_typed_params(
2732            "tradjema",
2733            MaData::Slice(&prices),
2734            (40, 40, 0),
2735            Kernel::Auto,
2736            &[],
2737        )
2738        .unwrap_err()
2739        .to_string();
2740        assert!(err.contains("requires candles"));
2741    }
2742
2743    #[test]
2744    fn vwap_requires_candles_error() {
2745        let prices = sample_prices(256);
2746        let err = ma_batch_with_kernel_and_typed_params(
2747            "vwap",
2748            MaData::Slice(&prices),
2749            (10, 10, 0),
2750            Kernel::Auto,
2751            &[],
2752        )
2753        .unwrap_err()
2754        .to_string();
2755        assert!(err.contains("requires candles"));
2756    }
2757
2758    #[test]
2759    fn volume_adjusted_ma_requires_candles_error() {
2760        let prices = sample_prices(256);
2761        let err = ma_batch_with_kernel_and_typed_params(
2762            "volume_adjusted_ma",
2763            MaData::Slice(&prices),
2764            (20, 20, 0),
2765            Kernel::Auto,
2766            &[],
2767        )
2768        .unwrap_err()
2769        .to_string();
2770        assert!(err.contains("requires candles"));
2771    }
2772
2773    #[test]
2774    fn volume_adjusted_ma_invalid_strict_numeric_error() {
2775        let candles = sample_candles(300);
2776        let mut params = HashMap::new();
2777        params.insert("strict".to_string(), 2.0);
2778        let err = ma_batch_with_kernel_and_params(
2779            "volume_adjusted_ma",
2780            MaData::Candles {
2781                candles: &candles,
2782                source: "close",
2783            },
2784            (20, 20, 0),
2785            Kernel::Auto,
2786            Some(&params),
2787        )
2788        .unwrap_err()
2789        .to_string();
2790        assert!(err.contains("expected 0 or 1"));
2791    }
2792
2793    #[test]
2794    fn vwap_invalid_anchor_step_error() {
2795        let candles = sample_candles(300);
2796        let params = [
2797            MaBatchParamKV {
2798                key: "anchor",
2799                value: MaBatchParamValue::EnumString("1d"),
2800            },
2801            MaBatchParamKV {
2802                key: "anchor_step",
2803                value: MaBatchParamValue::Float(-1.0),
2804            },
2805        ];
2806        let err = ma_batch_with_kernel_and_typed_params(
2807            "vwap",
2808            MaData::Candles {
2809                candles: &candles,
2810                source: "close",
2811            },
2812            (10, 10, 0),
2813            Kernel::Auto,
2814            &params,
2815        )
2816        .unwrap_err()
2817        .to_string();
2818        assert!(err.contains("expected >= 0"));
2819    }
2820
2821    #[test]
2822    fn ehlers_pma_invalid_output_selection_returns_error() {
2823        let prices = sample_prices(256);
2824        let params = [MaBatchParamKV {
2825            key: "output",
2826            value: MaBatchParamValue::EnumString("bad_line"),
2827        }];
2828        let err = ma_batch_with_kernel_and_typed_params(
2829            "ehlers_pma",
2830            MaData::Slice(&prices),
2831            (10, 10, 0),
2832            Kernel::Auto,
2833            &params,
2834        )
2835        .unwrap_err()
2836        .to_string();
2837        assert!(err.contains("expected 'predict' or 'trigger'"));
2838    }
2839
2840    #[test]
2841    fn buff_averages_typed_output_selection_matches_direct() {
2842        let candles = sample_candles(300);
2843        let params = [
2844            MaBatchParamKV {
2845                key: "fast_period",
2846                value: MaBatchParamValue::Int(5),
2847            },
2848            MaBatchParamKV {
2849                key: "output",
2850                value: MaBatchParamValue::EnumString("slow"),
2851            },
2852        ];
2853        let got = ma_batch_with_kernel_and_typed_params(
2854            "buff_averages",
2855            MaData::Candles {
2856                candles: &candles,
2857                source: "close",
2858            },
2859            (20, 20, 0),
2860            Kernel::Auto,
2861            &params,
2862        )
2863        .unwrap();
2864        let direct =
2865            crate::indicators::moving_averages::buff_averages::buff_averages_batch_with_kernel(
2866                &candles.close,
2867                &candles.volume,
2868                &crate::indicators::moving_averages::buff_averages::BuffAveragesBatchRange {
2869                    fast_period: (5, 5, 0),
2870                    slow_period: (20, 20, 0),
2871                },
2872                Kernel::Auto,
2873            )
2874            .unwrap();
2875        assert_eq!(got.rows, direct.rows);
2876        assert_eq!(got.cols, direct.cols);
2877        assert_series_eq(&got.values, &direct.slow, 1e-12);
2878    }
2879
2880    #[test]
2881    fn buff_averages_requires_candles_error() {
2882        let prices = sample_prices(256);
2883        let err = ma_batch_with_kernel_and_typed_params(
2884            "buff_averages",
2885            MaData::Slice(&prices),
2886            (20, 20, 0),
2887            Kernel::Auto,
2888            &[],
2889        )
2890        .unwrap_err()
2891        .to_string();
2892        assert!(err.contains("requires candles"));
2893    }
2894
2895    #[test]
2896    fn buff_averages_invalid_output_selection_returns_error() {
2897        let candles = sample_candles(300);
2898        let params = [MaBatchParamKV {
2899            key: "output",
2900            value: MaBatchParamValue::EnumString("bad_line"),
2901        }];
2902        let err = ma_batch_with_kernel_and_typed_params(
2903            "buff_averages",
2904            MaData::Candles {
2905                candles: &candles,
2906                source: "close",
2907            },
2908            (20, 20, 0),
2909            Kernel::Auto,
2910            &params,
2911        )
2912        .unwrap_err()
2913        .to_string();
2914        assert!(err.contains("expected 'fast' or 'slow'"));
2915    }
2916
2917    #[test]
2918    fn buff_averages_numeric_params_match_direct_fast() {
2919        let candles = sample_candles(300);
2920        let mut params = HashMap::new();
2921        params.insert("fast_period_start".to_string(), 5.0);
2922        params.insert("fast_period_end".to_string(), 5.0);
2923        params.insert("fast_period_step".to_string(), 0.0);
2924        params.insert("slow_period_start".to_string(), 20.0);
2925        params.insert("slow_period_end".to_string(), 22.0);
2926        params.insert("slow_period_step".to_string(), 1.0);
2927
2928        let got = ma_batch_with_kernel_and_params(
2929            "buff_averages",
2930            MaData::Candles {
2931                candles: &candles,
2932                source: "close",
2933            },
2934            (20, 22, 1),
2935            Kernel::Auto,
2936            Some(&params),
2937        )
2938        .unwrap();
2939
2940        let direct =
2941            crate::indicators::moving_averages::buff_averages::buff_averages_batch_with_kernel(
2942                &candles.close,
2943                &candles.volume,
2944                &crate::indicators::moving_averages::buff_averages::BuffAveragesBatchRange {
2945                    fast_period: (5, 5, 0),
2946                    slow_period: (20, 22, 1),
2947                },
2948                Kernel::Auto,
2949            )
2950            .unwrap();
2951
2952        assert_eq!(got.periods, vec![20, 21, 22]);
2953        assert_eq!(got.rows, direct.rows);
2954        assert_eq!(got.cols, direct.cols);
2955        assert_series_eq(&got.values, &direct.fast, 1e-12);
2956    }
2957
2958    #[test]
2959    fn typed_non_finite_float_rejected() {
2960        let prices = sample_prices(256);
2961        let params = [MaBatchParamKV {
2962            key: "offset",
2963            value: MaBatchParamValue::Float(f64::NAN),
2964        }];
2965        let err = ma_batch_with_kernel_and_typed_params(
2966            "alma",
2967            MaData::Slice(&prices),
2968            (20, 20, 0),
2969            Kernel::Auto,
2970            &params,
2971        )
2972        .unwrap_err()
2973        .to_string();
2974        assert!(err.contains("expected finite number"));
2975    }
2976
2977    #[test]
2978    fn uma_typed_integer_param_rejects_fractional_value() {
2979        let prices = sample_prices(256);
2980        let params = [MaBatchParamKV {
2981            key: "min_length",
2982            value: MaBatchParamValue::Float(7.25),
2983        }];
2984        let err = ma_batch_with_kernel_and_typed_params(
2985            "uma",
2986            MaData::Slice(&prices),
2987            (35, 35, 0),
2988            Kernel::Auto,
2989            &params,
2990        )
2991        .unwrap_err()
2992        .to_string();
2993        assert!(err.contains("expected integer"));
2994    }
2995
2996    #[test]
2997    fn buff_averages_typed_integer_param_rejects_fractional_value() {
2998        let candles = sample_candles(300);
2999        let params = [MaBatchParamKV {
3000            key: "fast_period",
3001            value: MaBatchParamValue::Float(5.5),
3002        }];
3003        let err = ma_batch_with_kernel_and_typed_params(
3004            "buff_averages",
3005            MaData::Candles {
3006                candles: &candles,
3007                source: "close",
3008            },
3009            (20, 20, 0),
3010            Kernel::Auto,
3011            &params,
3012        )
3013        .unwrap_err()
3014        .to_string();
3015        assert!(err.contains("expected integer"));
3016    }
3017
3018    #[test]
3019    fn vwap_typed_anchor_step_rejects_fractional_value() {
3020        let candles = sample_candles(300);
3021        let params = [
3022            MaBatchParamKV {
3023                key: "anchor",
3024                value: MaBatchParamValue::EnumString("1d"),
3025            },
3026            MaBatchParamKV {
3027                key: "anchor_step",
3028                value: MaBatchParamValue::Float(1.5),
3029            },
3030        ];
3031        let err = ma_batch_with_kernel_and_typed_params(
3032            "vwap",
3033            MaData::Candles {
3034                candles: &candles,
3035                source: "close",
3036            },
3037            (10, 10, 0),
3038            Kernel::Auto,
3039            &params,
3040        )
3041        .unwrap_err()
3042        .to_string();
3043        assert!(err.contains("expected integer"));
3044    }
3045
3046    #[test]
3047    fn mwdx_typed_non_finite_factor_rejected() {
3048        let prices = sample_prices(256);
3049        let params = [MaBatchParamKV {
3050            key: "factor",
3051            value: MaBatchParamValue::Float(f64::INFINITY),
3052        }];
3053        let err = ma_batch_with_kernel_and_typed_params(
3054            "mwdx",
3055            MaData::Slice(&prices),
3056            (10, 10, 0),
3057            Kernel::Auto,
3058            &params,
3059        )
3060        .unwrap_err()
3061        .to_string();
3062        assert!(err.contains("expected finite number"));
3063    }
3064
3065    #[test]
3066    fn hwma_typed_non_finite_param_rejected() {
3067        let prices = sample_prices(256);
3068        let params = [MaBatchParamKV {
3069            key: "na",
3070            value: MaBatchParamValue::Float(f64::NAN),
3071        }];
3072        let err = ma_batch_with_kernel_and_typed_params(
3073            "hwma",
3074            MaData::Slice(&prices),
3075            (10, 10, 0),
3076            Kernel::Auto,
3077            &params,
3078        )
3079        .unwrap_err()
3080        .to_string();
3081        assert!(err.contains("expected finite number"));
3082    }
3083
3084    #[test]
3085    fn dma_typed_fractional_ema_length_rejected() {
3086        let prices = sample_prices(256);
3087        let params = [
3088            MaBatchParamKV {
3089                key: "ema_length",
3090                value: MaBatchParamValue::Float(20.5),
3091            },
3092            MaBatchParamKV {
3093                key: "hull_ma_type",
3094                value: MaBatchParamValue::EnumString("EMA"),
3095            },
3096        ];
3097        let err = ma_batch_with_kernel_and_typed_params(
3098            "dma",
3099            MaData::Slice(&prices),
3100            (14, 14, 0),
3101            Kernel::Auto,
3102            &params,
3103        )
3104        .unwrap_err()
3105        .to_string();
3106        assert!(err.contains("expected integer"));
3107    }
3108
3109    #[test]
3110    fn tradjema_typed_non_finite_mult_rejected() {
3111        let candles = sample_candles(300);
3112        let params = [MaBatchParamKV {
3113            key: "mult",
3114            value: MaBatchParamValue::Float(f64::NAN),
3115        }];
3116        let err = ma_batch_with_kernel_and_typed_params(
3117            "tradjema",
3118            MaData::Candles {
3119                candles: &candles,
3120                source: "close",
3121            },
3122            (40, 40, 0),
3123            Kernel::Auto,
3124            &params,
3125        )
3126        .unwrap_err()
3127        .to_string();
3128        assert!(err.contains("expected finite number"));
3129    }
3130
3131    #[test]
3132    fn uma_typed_negative_min_length_rejected() {
3133        let prices = sample_prices(256);
3134        let params = [MaBatchParamKV {
3135            key: "min_length",
3136            value: MaBatchParamValue::Int(-1),
3137        }];
3138        let err = ma_batch_with_kernel_and_typed_params(
3139            "uma",
3140            MaData::Slice(&prices),
3141            (35, 35, 0),
3142            Kernel::Auto,
3143            &params,
3144        )
3145        .unwrap_err()
3146        .to_string();
3147        assert!(err.contains("expected >= 0"));
3148    }
3149
3150    #[test]
3151    fn volume_adjusted_ma_typed_fractional_sample_period_rejected() {
3152        let candles = sample_candles(300);
3153        let params = [MaBatchParamKV {
3154            key: "sample_period",
3155            value: MaBatchParamValue::Float(30.5),
3156        }];
3157        let err = ma_batch_with_kernel_and_typed_params(
3158            "volume_adjusted_ma",
3159            MaData::Candles {
3160                candles: &candles,
3161                source: "close",
3162            },
3163            (20, 20, 0),
3164            Kernel::Auto,
3165            &params,
3166        )
3167        .unwrap_err()
3168        .to_string();
3169        assert!(err.contains("expected integer"));
3170    }
3171}