Skip to main content

vector_ta/indicators/
accumulation_swing_index.rs

1#[cfg(feature = "python")]
2use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
3#[cfg(feature = "python")]
4use pyo3::exceptions::PyValueError;
5#[cfg(feature = "python")]
6use pyo3::prelude::*;
7#[cfg(feature = "python")]
8use pyo3::types::PyDict;
9
10#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
11use serde::{Deserialize, Serialize};
12#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
13use wasm_bindgen::prelude::*;
14
15use crate::utilities::data_loader::Candles;
16use crate::utilities::enums::Kernel;
17use crate::utilities::helpers::{
18    alloc_with_nan_prefix, detect_best_batch_kernel, init_matrix_prefixes, make_uninit_matrix,
19};
20#[cfg(feature = "python")]
21use crate::utilities::kernel_validation::validate_kernel;
22#[cfg(not(target_arch = "wasm32"))]
23use rayon::prelude::*;
24use std::mem::ManuallyDrop;
25use thiserror::Error;
26
27const DEFAULT_DAILY_LIMIT: f64 = 10_000.0;
28
29#[derive(Debug, Clone)]
30pub enum AccumulationSwingIndexData<'a> {
31    Candles {
32        candles: &'a Candles,
33    },
34    Slices {
35        open: &'a [f64],
36        high: &'a [f64],
37        low: &'a [f64],
38        close: &'a [f64],
39    },
40}
41
42#[derive(Debug, Clone)]
43pub struct AccumulationSwingIndexOutput {
44    pub values: Vec<f64>,
45}
46
47#[derive(Debug, Clone)]
48#[cfg_attr(
49    all(target_arch = "wasm32", feature = "wasm"),
50    derive(Serialize, Deserialize)
51)]
52pub struct AccumulationSwingIndexParams {
53    pub daily_limit: Option<f64>,
54}
55
56impl Default for AccumulationSwingIndexParams {
57    fn default() -> Self {
58        Self {
59            daily_limit: Some(DEFAULT_DAILY_LIMIT),
60        }
61    }
62}
63
64#[derive(Debug, Clone)]
65pub struct AccumulationSwingIndexInput<'a> {
66    pub data: AccumulationSwingIndexData<'a>,
67    pub params: AccumulationSwingIndexParams,
68}
69
70impl<'a> AccumulationSwingIndexInput<'a> {
71    #[inline]
72    pub fn from_candles(candles: &'a Candles, params: AccumulationSwingIndexParams) -> Self {
73        Self {
74            data: AccumulationSwingIndexData::Candles { candles },
75            params,
76        }
77    }
78
79    #[inline]
80    pub fn from_slices(
81        open: &'a [f64],
82        high: &'a [f64],
83        low: &'a [f64],
84        close: &'a [f64],
85        params: AccumulationSwingIndexParams,
86    ) -> Self {
87        Self {
88            data: AccumulationSwingIndexData::Slices {
89                open,
90                high,
91                low,
92                close,
93            },
94            params,
95        }
96    }
97
98    #[inline]
99    pub fn with_default_candles(candles: &'a Candles) -> Self {
100        Self::from_candles(candles, AccumulationSwingIndexParams::default())
101    }
102
103    #[inline]
104    pub fn get_daily_limit(&self) -> f64 {
105        self.params.daily_limit.unwrap_or(DEFAULT_DAILY_LIMIT)
106    }
107}
108
109#[derive(Copy, Clone, Debug)]
110pub struct AccumulationSwingIndexBuilder {
111    daily_limit: Option<f64>,
112    kernel: Kernel,
113}
114
115impl Default for AccumulationSwingIndexBuilder {
116    fn default() -> Self {
117        Self {
118            daily_limit: None,
119            kernel: Kernel::Auto,
120        }
121    }
122}
123
124impl AccumulationSwingIndexBuilder {
125    #[inline(always)]
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    #[inline(always)]
131    pub fn daily_limit(mut self, value: f64) -> Self {
132        self.daily_limit = Some(value);
133        self
134    }
135
136    #[inline(always)]
137    pub fn kernel(mut self, value: Kernel) -> Self {
138        self.kernel = value;
139        self
140    }
141
142    #[inline(always)]
143    pub fn apply(
144        self,
145        candles: &Candles,
146    ) -> Result<AccumulationSwingIndexOutput, AccumulationSwingIndexError> {
147        let input = AccumulationSwingIndexInput::from_candles(
148            candles,
149            AccumulationSwingIndexParams {
150                daily_limit: self.daily_limit,
151            },
152        );
153        accumulation_swing_index_with_kernel(&input, self.kernel)
154    }
155
156    #[inline(always)]
157    pub fn apply_slices(
158        self,
159        open: &[f64],
160        high: &[f64],
161        low: &[f64],
162        close: &[f64],
163    ) -> Result<AccumulationSwingIndexOutput, AccumulationSwingIndexError> {
164        let input = AccumulationSwingIndexInput::from_slices(
165            open,
166            high,
167            low,
168            close,
169            AccumulationSwingIndexParams {
170                daily_limit: self.daily_limit,
171            },
172        );
173        accumulation_swing_index_with_kernel(&input, self.kernel)
174    }
175
176    #[inline(always)]
177    pub fn into_stream(self) -> Result<AccumulationSwingIndexStream, AccumulationSwingIndexError> {
178        AccumulationSwingIndexStream::try_new(AccumulationSwingIndexParams {
179            daily_limit: self.daily_limit,
180        })
181    }
182}
183
184#[derive(Debug, Error)]
185pub enum AccumulationSwingIndexError {
186    #[error("accumulation_swing_index: Input data slice is empty.")]
187    EmptyInputData,
188    #[error("accumulation_swing_index: All values are NaN.")]
189    AllValuesNaN,
190    #[error("accumulation_swing_index: Inconsistent slice lengths: open={open_len}, high={high_len}, low={low_len}, close={close_len}")]
191    InconsistentSliceLengths {
192        open_len: usize,
193        high_len: usize,
194        low_len: usize,
195        close_len: usize,
196    },
197    #[error("accumulation_swing_index: Invalid daily_limit: {daily_limit}")]
198    InvalidDailyLimit { daily_limit: f64 },
199    #[error("accumulation_swing_index: Output length mismatch: expected={expected}, got={got}")]
200    OutputLengthMismatch { expected: usize, got: usize },
201    #[error("accumulation_swing_index: Invalid range: start={start}, end={end}, step={step}")]
202    InvalidRange {
203        start: String,
204        end: String,
205        step: String,
206    },
207    #[error("accumulation_swing_index: Invalid kernel for batch: {0:?}")]
208    InvalidKernelForBatch(Kernel),
209}
210
211#[inline(always)]
212fn extract_ohlc<'a>(
213    input: &'a AccumulationSwingIndexInput<'a>,
214) -> Result<(&'a [f64], &'a [f64], &'a [f64], &'a [f64]), AccumulationSwingIndexError> {
215    let (open, high, low, close) = match &input.data {
216        AccumulationSwingIndexData::Candles { candles } => (
217            candles.open.as_slice(),
218            candles.high.as_slice(),
219            candles.low.as_slice(),
220            candles.close.as_slice(),
221        ),
222        AccumulationSwingIndexData::Slices {
223            open,
224            high,
225            low,
226            close,
227        } => (*open, *high, *low, *close),
228    };
229
230    if open.is_empty() || high.is_empty() || low.is_empty() || close.is_empty() {
231        return Err(AccumulationSwingIndexError::EmptyInputData);
232    }
233    if open.len() != high.len() || open.len() != low.len() || open.len() != close.len() {
234        return Err(AccumulationSwingIndexError::InconsistentSliceLengths {
235            open_len: open.len(),
236            high_len: high.len(),
237            low_len: low.len(),
238            close_len: close.len(),
239        });
240    }
241    Ok((open, high, low, close))
242}
243
244#[inline(always)]
245fn first_valid_ohlc(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Option<usize> {
246    (0..close.len()).find(|&i| {
247        open[i].is_finite() && high[i].is_finite() && low[i].is_finite() && close[i].is_finite()
248    })
249}
250
251#[inline(always)]
252fn prepare<'a>(
253    input: &'a AccumulationSwingIndexInput<'a>,
254    kernel: Kernel,
255) -> Result<
256    (
257        &'a [f64],
258        &'a [f64],
259        &'a [f64],
260        &'a [f64],
261        f64,
262        usize,
263        Kernel,
264    ),
265    AccumulationSwingIndexError,
266> {
267    let (open, high, low, close) = extract_ohlc(input)?;
268    let daily_limit = input.get_daily_limit();
269    if !daily_limit.is_finite() || daily_limit <= 0.0 {
270        return Err(AccumulationSwingIndexError::InvalidDailyLimit { daily_limit });
271    }
272    let first = first_valid_ohlc(open, high, low, close)
273        .ok_or(AccumulationSwingIndexError::AllValuesNaN)?;
274    Ok((
275        open,
276        high,
277        low,
278        close,
279        daily_limit,
280        first,
281        kernel.to_non_batch(),
282    ))
283}
284
285#[inline(always)]
286fn compute_increment(
287    prev_open: f64,
288    prev_close: f64,
289    open: f64,
290    high: f64,
291    low: f64,
292    close: f64,
293    daily_limit: f64,
294) -> f64 {
295    let abs_high_close = (high - prev_close).abs();
296    let abs_low_close = (low - prev_close).abs();
297    let abs_close_open = (prev_close - prev_open).abs();
298    let k = if abs_high_close >= abs_low_close {
299        abs_high_close
300    } else {
301        abs_low_close
302    };
303    let range = high - low;
304    let r = if abs_high_close >= abs_low_close {
305        if abs_high_close >= range {
306            abs_high_close - 0.5 * abs_low_close + 0.25 * abs_close_open
307        } else {
308            range + 0.25 * abs_close_open
309        }
310    } else if abs_low_close >= range {
311        abs_low_close - 0.5 * abs_high_close + 0.25 * abs_close_open
312    } else {
313        range + 0.25 * abs_close_open
314    };
315
316    if r != 0.0 {
317        50.0 * (((close - prev_close) + 0.5 * (close - open) + 0.25 * (prev_close - prev_open)) / r)
318            * k
319            / daily_limit
320    } else {
321        0.0
322    }
323}
324
325#[inline(always)]
326fn compute_accumulation_swing_index_into(
327    open: &[f64],
328    high: &[f64],
329    low: &[f64],
330    close: &[f64],
331    daily_limit: f64,
332    first: usize,
333    out: &mut [f64],
334) {
335    let n = close.len();
336    if first >= n {
337        return;
338    }
339
340    let mut accum = 0.0;
341    out[first] = 0.0;
342    let mut prev_open = open[first];
343    let mut prev_close = close[first];
344
345    let mut i = first + 1;
346    while i < n {
347        let o = open[i];
348        let h = high[i];
349        let l = low[i];
350        let c = close[i];
351        if o.is_finite()
352            && h.is_finite()
353            && l.is_finite()
354            && c.is_finite()
355            && prev_open.is_finite()
356            && prev_close.is_finite()
357        {
358            let delta = compute_increment(prev_open, prev_close, o, h, l, c, daily_limit);
359            if delta.is_finite() {
360                accum += delta;
361            }
362        }
363        out[i] = accum;
364        prev_open = o;
365        prev_close = c;
366        i += 1;
367    }
368}
369
370#[inline]
371pub fn accumulation_swing_index(
372    input: &AccumulationSwingIndexInput,
373) -> Result<AccumulationSwingIndexOutput, AccumulationSwingIndexError> {
374    accumulation_swing_index_with_kernel(input, Kernel::Auto)
375}
376
377pub fn accumulation_swing_index_with_kernel(
378    input: &AccumulationSwingIndexInput,
379    kernel: Kernel,
380) -> Result<AccumulationSwingIndexOutput, AccumulationSwingIndexError> {
381    let (open, high, low, close, daily_limit, first, _) = prepare(input, kernel)?;
382    let mut out = alloc_with_nan_prefix(close.len(), first);
383    compute_accumulation_swing_index_into(open, high, low, close, daily_limit, first, &mut out);
384    Ok(AccumulationSwingIndexOutput { values: out })
385}
386
387#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
388pub fn accumulation_swing_index_into(
389    out: &mut [f64],
390    input: &AccumulationSwingIndexInput,
391    kernel: Kernel,
392) -> Result<(), AccumulationSwingIndexError> {
393    accumulation_swing_index_into_slice(out, input, kernel)
394}
395
396pub fn accumulation_swing_index_into_slice(
397    out: &mut [f64],
398    input: &AccumulationSwingIndexInput,
399    kernel: Kernel,
400) -> Result<(), AccumulationSwingIndexError> {
401    let (open, high, low, close, daily_limit, first, _) = prepare(input, kernel)?;
402    let expected = close.len();
403    if out.len() != expected {
404        return Err(AccumulationSwingIndexError::OutputLengthMismatch {
405            expected,
406            got: out.len(),
407        });
408    }
409    out[..first.min(expected)].fill(f64::NAN);
410    compute_accumulation_swing_index_into(open, high, low, close, daily_limit, first, out);
411    Ok(())
412}
413
414#[derive(Debug, Clone)]
415pub struct AccumulationSwingIndexStream {
416    daily_limit: f64,
417    started: bool,
418    prev_open: f64,
419    prev_close: f64,
420    accum: f64,
421}
422
423impl AccumulationSwingIndexStream {
424    pub fn try_new(
425        params: AccumulationSwingIndexParams,
426    ) -> Result<Self, AccumulationSwingIndexError> {
427        let daily_limit = params.daily_limit.unwrap_or(DEFAULT_DAILY_LIMIT);
428        if !daily_limit.is_finite() || daily_limit <= 0.0 {
429            return Err(AccumulationSwingIndexError::InvalidDailyLimit { daily_limit });
430        }
431        Ok(Self {
432            daily_limit,
433            started: false,
434            prev_open: f64::NAN,
435            prev_close: f64::NAN,
436            accum: 0.0,
437        })
438    }
439
440    #[inline]
441    pub fn update(&mut self, open: f64, high: f64, low: f64, close: f64) -> f64 {
442        if !self.started {
443            if open.is_finite() && high.is_finite() && low.is_finite() && close.is_finite() {
444                self.started = true;
445                self.prev_open = open;
446                self.prev_close = close;
447                self.accum = 0.0;
448                return 0.0;
449            }
450            return f64::NAN;
451        }
452
453        if open.is_finite()
454            && high.is_finite()
455            && low.is_finite()
456            && close.is_finite()
457            && self.prev_open.is_finite()
458            && self.prev_close.is_finite()
459        {
460            let delta = compute_increment(
461                self.prev_open,
462                self.prev_close,
463                open,
464                high,
465                low,
466                close,
467                self.daily_limit,
468            );
469            if delta.is_finite() {
470                self.accum += delta;
471            }
472        }
473
474        self.prev_open = open;
475        self.prev_close = close;
476        self.accum
477    }
478}
479
480#[derive(Debug, Clone)]
481pub struct AccumulationSwingIndexBatchRange {
482    pub daily_limit: (f64, f64, f64),
483}
484
485#[derive(Debug, Clone)]
486pub struct AccumulationSwingIndexBatchOutput {
487    pub values: Vec<f64>,
488    pub combos: Vec<AccumulationSwingIndexParams>,
489    pub rows: usize,
490    pub cols: usize,
491}
492
493impl AccumulationSwingIndexBatchOutput {
494    pub fn row_for_params(&self, params: &AccumulationSwingIndexParams) -> Option<usize> {
495        let daily_limit = params.daily_limit.unwrap_or(DEFAULT_DAILY_LIMIT);
496        self.combos.iter().position(|combo| {
497            (combo.daily_limit.unwrap_or(DEFAULT_DAILY_LIMIT) - daily_limit).abs() <= 1e-12
498        })
499    }
500
501    pub fn values_for(&self, params: &AccumulationSwingIndexParams) -> Option<&[f64]> {
502        self.row_for_params(params).and_then(|row| {
503            let start = row * self.cols;
504            self.values.get(start..start + self.cols)
505        })
506    }
507}
508
509#[derive(Clone, Debug)]
510pub struct AccumulationSwingIndexBatchBuilder {
511    range: AccumulationSwingIndexBatchRange,
512    kernel: Kernel,
513}
514
515impl Default for AccumulationSwingIndexBatchBuilder {
516    fn default() -> Self {
517        Self {
518            range: AccumulationSwingIndexBatchRange {
519                daily_limit: (DEFAULT_DAILY_LIMIT, DEFAULT_DAILY_LIMIT, 0.0),
520            },
521            kernel: Kernel::Auto,
522        }
523    }
524}
525
526impl AccumulationSwingIndexBatchBuilder {
527    #[inline(always)]
528    pub fn new() -> Self {
529        Self::default()
530    }
531
532    #[inline(always)]
533    pub fn kernel(mut self, kernel: Kernel) -> Self {
534        self.kernel = kernel;
535        self
536    }
537
538    #[inline(always)]
539    pub fn daily_limit_range(mut self, start: f64, end: f64, step: f64) -> Self {
540        self.range.daily_limit = (start, end, step);
541        self
542    }
543
544    #[inline(always)]
545    pub fn daily_limit_static(mut self, value: f64) -> Self {
546        self.range.daily_limit = (value, value, 0.0);
547        self
548    }
549
550    #[inline(always)]
551    pub fn apply_slices(
552        self,
553        open: &[f64],
554        high: &[f64],
555        low: &[f64],
556        close: &[f64],
557    ) -> Result<AccumulationSwingIndexBatchOutput, AccumulationSwingIndexError> {
558        accumulation_swing_index_batch_with_kernel(open, high, low, close, &self.range, self.kernel)
559    }
560
561    #[inline(always)]
562    pub fn apply_candles(
563        self,
564        candles: &Candles,
565    ) -> Result<AccumulationSwingIndexBatchOutput, AccumulationSwingIndexError> {
566        self.apply_slices(
567            candles.open.as_slice(),
568            candles.high.as_slice(),
569            candles.low.as_slice(),
570            candles.close.as_slice(),
571        )
572    }
573}
574
575#[inline(always)]
576fn axis_f64((start, end, step): (f64, f64, f64)) -> Result<Vec<f64>, AccumulationSwingIndexError> {
577    if !start.is_finite() || !end.is_finite() || !step.is_finite() {
578        return Err(AccumulationSwingIndexError::InvalidRange {
579            start: start.to_string(),
580            end: end.to_string(),
581            step: step.to_string(),
582        });
583    }
584    if step.abs() < 1e-12 || (start - end).abs() < 1e-12 {
585        return Ok(vec![start]);
586    }
587    let step_abs = step.abs();
588    let mut out = Vec::new();
589    if start < end {
590        let mut x = start;
591        while x <= end + 1e-12 {
592            out.push(x);
593            x += step_abs;
594        }
595    } else {
596        let mut x = start;
597        while x >= end - 1e-12 {
598            out.push(x);
599            x -= step_abs;
600        }
601    }
602    if out.is_empty() {
603        return Err(AccumulationSwingIndexError::InvalidRange {
604            start: start.to_string(),
605            end: end.to_string(),
606            step: step.to_string(),
607        });
608    }
609    Ok(out)
610}
611
612#[inline(always)]
613pub fn expand_grid(
614    range: &AccumulationSwingIndexBatchRange,
615) -> Result<Vec<AccumulationSwingIndexParams>, AccumulationSwingIndexError> {
616    Ok(axis_f64(range.daily_limit)?
617        .into_iter()
618        .map(|daily_limit| AccumulationSwingIndexParams {
619            daily_limit: Some(daily_limit),
620        })
621        .collect())
622}
623
624pub fn accumulation_swing_index_batch_with_kernel(
625    open: &[f64],
626    high: &[f64],
627    low: &[f64],
628    close: &[f64],
629    sweep: &AccumulationSwingIndexBatchRange,
630    kernel: Kernel,
631) -> Result<AccumulationSwingIndexBatchOutput, AccumulationSwingIndexError> {
632    let batch_kernel = match kernel {
633        Kernel::Auto => detect_best_batch_kernel(),
634        other if other.is_batch() => other,
635        _ => return Err(AccumulationSwingIndexError::InvalidKernelForBatch(kernel)),
636    };
637    accumulation_swing_index_batch_par_slice(
638        open,
639        high,
640        low,
641        close,
642        sweep,
643        batch_kernel.to_non_batch(),
644    )
645}
646
647#[inline(always)]
648pub fn accumulation_swing_index_batch_slice(
649    open: &[f64],
650    high: &[f64],
651    low: &[f64],
652    close: &[f64],
653    sweep: &AccumulationSwingIndexBatchRange,
654    kernel: Kernel,
655) -> Result<AccumulationSwingIndexBatchOutput, AccumulationSwingIndexError> {
656    accumulation_swing_index_batch_inner(open, high, low, close, sweep, kernel, false)
657}
658
659#[inline(always)]
660pub fn accumulation_swing_index_batch_par_slice(
661    open: &[f64],
662    high: &[f64],
663    low: &[f64],
664    close: &[f64],
665    sweep: &AccumulationSwingIndexBatchRange,
666    kernel: Kernel,
667) -> Result<AccumulationSwingIndexBatchOutput, AccumulationSwingIndexError> {
668    accumulation_swing_index_batch_inner(open, high, low, close, sweep, kernel, true)
669}
670
671fn validate_raw_slices(
672    open: &[f64],
673    high: &[f64],
674    low: &[f64],
675    close: &[f64],
676) -> Result<usize, AccumulationSwingIndexError> {
677    if open.is_empty() || high.is_empty() || low.is_empty() || close.is_empty() {
678        return Err(AccumulationSwingIndexError::EmptyInputData);
679    }
680    if open.len() != high.len() || open.len() != low.len() || open.len() != close.len() {
681        return Err(AccumulationSwingIndexError::InconsistentSliceLengths {
682            open_len: open.len(),
683            high_len: high.len(),
684            low_len: low.len(),
685            close_len: close.len(),
686        });
687    }
688    first_valid_ohlc(open, high, low, close).ok_or(AccumulationSwingIndexError::AllValuesNaN)
689}
690
691fn accumulation_swing_index_batch_inner(
692    open: &[f64],
693    high: &[f64],
694    low: &[f64],
695    close: &[f64],
696    sweep: &AccumulationSwingIndexBatchRange,
697    kernel: Kernel,
698    parallel: bool,
699) -> Result<AccumulationSwingIndexBatchOutput, AccumulationSwingIndexError> {
700    let combos = expand_grid(sweep)?;
701    let first = validate_raw_slices(open, high, low, close)?;
702    let rows = combos.len();
703    let cols = close.len();
704    let warmups = vec![first; rows];
705
706    let mut buf = make_uninit_matrix(rows, cols);
707    init_matrix_prefixes(&mut buf, cols, &warmups);
708    let mut guard = ManuallyDrop::new(buf);
709    let out: &mut [f64] =
710        unsafe { core::slice::from_raw_parts_mut(guard.as_mut_ptr() as *mut f64, guard.len()) };
711
712    accumulation_swing_index_batch_inner_into(
713        open, high, low, close, sweep, kernel, parallel, out,
714    )?;
715
716    let values = unsafe {
717        Vec::from_raw_parts(
718            guard.as_mut_ptr() as *mut f64,
719            guard.len(),
720            guard.capacity(),
721        )
722    };
723
724    Ok(AccumulationSwingIndexBatchOutput {
725        values,
726        combos,
727        rows,
728        cols,
729    })
730}
731
732pub fn accumulation_swing_index_batch_into_slice(
733    out: &mut [f64],
734    open: &[f64],
735    high: &[f64],
736    low: &[f64],
737    close: &[f64],
738    sweep: &AccumulationSwingIndexBatchRange,
739    kernel: Kernel,
740) -> Result<(), AccumulationSwingIndexError> {
741    accumulation_swing_index_batch_inner_into(open, high, low, close, sweep, kernel, false, out)?;
742    Ok(())
743}
744
745fn accumulation_swing_index_batch_inner_into(
746    open: &[f64],
747    high: &[f64],
748    low: &[f64],
749    close: &[f64],
750    sweep: &AccumulationSwingIndexBatchRange,
751    _kernel: Kernel,
752    parallel: bool,
753    out: &mut [f64],
754) -> Result<Vec<AccumulationSwingIndexParams>, AccumulationSwingIndexError> {
755    let combos = expand_grid(sweep)?;
756    let first = validate_raw_slices(open, high, low, close)?;
757    let rows = combos.len();
758    let cols = close.len();
759    let expected =
760        rows.checked_mul(cols)
761            .ok_or_else(|| AccumulationSwingIndexError::InvalidRange {
762                start: rows.to_string(),
763                end: cols.to_string(),
764                step: "rows*cols".to_string(),
765            })?;
766    if out.len() != expected {
767        return Err(AccumulationSwingIndexError::OutputLengthMismatch {
768            expected,
769            got: out.len(),
770        });
771    }
772
773    let daily_limits: Vec<f64> = combos
774        .iter()
775        .map(|combo| combo.daily_limit.unwrap_or(DEFAULT_DAILY_LIMIT))
776        .collect();
777    for &daily_limit in &daily_limits {
778        if !daily_limit.is_finite() || daily_limit <= 0.0 {
779            return Err(AccumulationSwingIndexError::InvalidDailyLimit { daily_limit });
780        }
781    }
782
783    let do_row = |row: usize, dst: &mut [f64]| {
784        dst[..first.min(cols)].fill(f64::NAN);
785        compute_accumulation_swing_index_into(
786            open,
787            high,
788            low,
789            close,
790            daily_limits[row],
791            first,
792            dst,
793        );
794    };
795
796    if parallel {
797        #[cfg(not(target_arch = "wasm32"))]
798        {
799            out.par_chunks_mut(cols)
800                .enumerate()
801                .for_each(|(row, dst)| do_row(row, dst));
802        }
803        #[cfg(target_arch = "wasm32")]
804        {
805            for (row, dst) in out.chunks_mut(cols).enumerate() {
806                do_row(row, dst);
807            }
808        }
809    } else {
810        for (row, dst) in out.chunks_mut(cols).enumerate() {
811            do_row(row, dst);
812        }
813    }
814
815    Ok(combos)
816}
817
818#[cfg(feature = "python")]
819#[pyfunction(name = "accumulation_swing_index")]
820#[pyo3(signature = (open, high, low, close, daily_limit=10000.0, kernel=None))]
821pub fn accumulation_swing_index_py<'py>(
822    py: Python<'py>,
823    open: PyReadonlyArray1<'py, f64>,
824    high: PyReadonlyArray1<'py, f64>,
825    low: PyReadonlyArray1<'py, f64>,
826    close: PyReadonlyArray1<'py, f64>,
827    daily_limit: f64,
828    kernel: Option<&str>,
829) -> PyResult<Bound<'py, PyArray1<f64>>> {
830    let open = open.as_slice()?;
831    let high = high.as_slice()?;
832    let low = low.as_slice()?;
833    let close = close.as_slice()?;
834    let kernel = validate_kernel(kernel, false)?;
835    let input = AccumulationSwingIndexInput::from_slices(
836        open,
837        high,
838        low,
839        close,
840        AccumulationSwingIndexParams {
841            daily_limit: Some(daily_limit),
842        },
843    );
844    let out = py
845        .allow_threads(|| accumulation_swing_index_with_kernel(&input, kernel))
846        .map_err(|e| PyValueError::new_err(e.to_string()))?;
847    Ok(out.values.into_pyarray(py))
848}
849
850#[cfg(feature = "python")]
851#[pyclass(name = "AccumulationSwingIndexStream")]
852pub struct AccumulationSwingIndexStreamPy {
853    stream: AccumulationSwingIndexStream,
854}
855
856#[cfg(feature = "python")]
857#[pymethods]
858impl AccumulationSwingIndexStreamPy {
859    #[new]
860    #[pyo3(signature = (daily_limit=10000.0))]
861    fn new(daily_limit: f64) -> PyResult<Self> {
862        let stream = AccumulationSwingIndexStream::try_new(AccumulationSwingIndexParams {
863            daily_limit: Some(daily_limit),
864        })
865        .map_err(|e| PyValueError::new_err(e.to_string()))?;
866        Ok(Self { stream })
867    }
868
869    fn update(&mut self, open: f64, high: f64, low: f64, close: f64) -> f64 {
870        self.stream.update(open, high, low, close)
871    }
872}
873
874#[cfg(feature = "python")]
875#[pyfunction(name = "accumulation_swing_index_batch")]
876#[pyo3(signature = (open, high, low, close, daily_limit_range=(10000.0,10000.0,0.0), kernel=None))]
877pub fn accumulation_swing_index_batch_py<'py>(
878    py: Python<'py>,
879    open: PyReadonlyArray1<'py, f64>,
880    high: PyReadonlyArray1<'py, f64>,
881    low: PyReadonlyArray1<'py, f64>,
882    close: PyReadonlyArray1<'py, f64>,
883    daily_limit_range: (f64, f64, f64),
884    kernel: Option<&str>,
885) -> PyResult<Bound<'py, PyDict>> {
886    let open = open.as_slice()?;
887    let high = high.as_slice()?;
888    let low = low.as_slice()?;
889    let close = close.as_slice()?;
890    let sweep = AccumulationSwingIndexBatchRange {
891        daily_limit: daily_limit_range,
892    };
893    let combos = expand_grid(&sweep).map_err(|e| PyValueError::new_err(e.to_string()))?;
894    let rows = combos.len();
895    let cols = close.len();
896    let total = rows
897        .checked_mul(cols)
898        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
899
900    let out_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
901    let out_slice = unsafe { out_arr.as_slice_mut()? };
902    let kernel = validate_kernel(kernel, true)?;
903
904    py.allow_threads(|| {
905        let batch_kernel = match kernel {
906            Kernel::Auto => detect_best_batch_kernel(),
907            other => other,
908        };
909        accumulation_swing_index_batch_inner_into(
910            open,
911            high,
912            low,
913            close,
914            &sweep,
915            batch_kernel.to_non_batch(),
916            true,
917            out_slice,
918        )
919    })
920    .map_err(|e| PyValueError::new_err(e.to_string()))?;
921
922    let dict = PyDict::new(py);
923    dict.set_item("values", out_arr.reshape((rows, cols))?)?;
924    dict.set_item(
925        "daily_limits",
926        combos
927            .iter()
928            .map(|combo| combo.daily_limit.unwrap_or(DEFAULT_DAILY_LIMIT))
929            .collect::<Vec<_>>()
930            .into_pyarray(py),
931    )?;
932    dict.set_item("rows", rows)?;
933    dict.set_item("cols", cols)?;
934    Ok(dict)
935}
936
937#[cfg(feature = "python")]
938pub fn register_accumulation_swing_index_module(
939    m: &Bound<'_, pyo3::types::PyModule>,
940) -> PyResult<()> {
941    m.add_function(wrap_pyfunction!(accumulation_swing_index_py, m)?)?;
942    m.add_function(wrap_pyfunction!(accumulation_swing_index_batch_py, m)?)?;
943    m.add_class::<AccumulationSwingIndexStreamPy>()?;
944    Ok(())
945}
946
947#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
948#[wasm_bindgen(js_name = "accumulation_swing_index_js")]
949pub fn accumulation_swing_index_js(
950    open: &[f64],
951    high: &[f64],
952    low: &[f64],
953    close: &[f64],
954    daily_limit: f64,
955) -> Result<Vec<f64>, JsValue> {
956    let input = AccumulationSwingIndexInput::from_slices(
957        open,
958        high,
959        low,
960        close,
961        AccumulationSwingIndexParams {
962            daily_limit: Some(daily_limit),
963        },
964    );
965    let out = accumulation_swing_index_with_kernel(&input, Kernel::Auto)
966        .map_err(|e| JsValue::from_str(&e.to_string()))?;
967    Ok(out.values)
968}
969
970#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
971#[derive(Serialize, Deserialize)]
972pub struct AccumulationSwingIndexBatchConfig {
973    pub daily_limit_range: Vec<f64>,
974}
975
976#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
977#[derive(Serialize, Deserialize)]
978pub struct AccumulationSwingIndexBatchJsOutput {
979    pub values: Vec<f64>,
980    pub daily_limits: Vec<f64>,
981    pub rows: usize,
982    pub cols: usize,
983}
984
985#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
986fn js_vec3_to_f64(name: &str, values: &[f64]) -> Result<(f64, f64, f64), JsValue> {
987    if values.len() != 3 {
988        return Err(JsValue::from_str(&format!(
989            "Invalid config: {name} must have exactly 3 elements [start, end, step]"
990        )));
991    }
992    if !values.iter().all(|v| v.is_finite()) {
993        return Err(JsValue::from_str(&format!(
994            "Invalid config: {name} entries must be finite numbers"
995        )));
996    }
997    Ok((values[0], values[1], values[2]))
998}
999
1000#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1001#[wasm_bindgen(js_name = "accumulation_swing_index_batch_js")]
1002pub fn accumulation_swing_index_batch_js(
1003    open: &[f64],
1004    high: &[f64],
1005    low: &[f64],
1006    close: &[f64],
1007    config: JsValue,
1008) -> Result<JsValue, JsValue> {
1009    let config: AccumulationSwingIndexBatchConfig = serde_wasm_bindgen::from_value(config)
1010        .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
1011    let sweep = AccumulationSwingIndexBatchRange {
1012        daily_limit: js_vec3_to_f64("daily_limit_range", &config.daily_limit_range)?,
1013    };
1014    let out =
1015        accumulation_swing_index_batch_with_kernel(open, high, low, close, &sweep, Kernel::Auto)
1016            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1017    let daily_limits = out
1018        .combos
1019        .iter()
1020        .map(|combo| combo.daily_limit.unwrap_or(DEFAULT_DAILY_LIMIT))
1021        .collect();
1022    serde_wasm_bindgen::to_value(&AccumulationSwingIndexBatchJsOutput {
1023        values: out.values,
1024        daily_limits,
1025        rows: out.rows,
1026        cols: out.cols,
1027    })
1028    .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
1029}
1030
1031#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1032#[wasm_bindgen]
1033pub fn accumulation_swing_index_alloc(len: usize) -> *mut f64 {
1034    let mut vec = Vec::<f64>::with_capacity(len);
1035    let ptr = vec.as_mut_ptr();
1036    std::mem::forget(vec);
1037    ptr
1038}
1039
1040#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1041#[wasm_bindgen]
1042pub fn accumulation_swing_index_free(ptr: *mut f64, len: usize) {
1043    if !ptr.is_null() {
1044        unsafe {
1045            let _ = Vec::from_raw_parts(ptr, len, len);
1046        }
1047    }
1048}
1049
1050#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1051#[wasm_bindgen]
1052pub fn accumulation_swing_index_into(
1053    open_ptr: *const f64,
1054    high_ptr: *const f64,
1055    low_ptr: *const f64,
1056    close_ptr: *const f64,
1057    out_ptr: *mut f64,
1058    len: usize,
1059    daily_limit: f64,
1060) -> Result<(), JsValue> {
1061    if open_ptr.is_null()
1062        || high_ptr.is_null()
1063        || low_ptr.is_null()
1064        || close_ptr.is_null()
1065        || out_ptr.is_null()
1066    {
1067        return Err(JsValue::from_str("Null pointer provided"));
1068    }
1069    unsafe {
1070        let open = std::slice::from_raw_parts(open_ptr, len);
1071        let high = std::slice::from_raw_parts(high_ptr, len);
1072        let low = std::slice::from_raw_parts(low_ptr, len);
1073        let close = std::slice::from_raw_parts(close_ptr, len);
1074        let out = std::slice::from_raw_parts_mut(out_ptr, len);
1075        let input = AccumulationSwingIndexInput::from_slices(
1076            open,
1077            high,
1078            low,
1079            close,
1080            AccumulationSwingIndexParams {
1081                daily_limit: Some(daily_limit),
1082            },
1083        );
1084        accumulation_swing_index_into_slice(out, &input, Kernel::Auto)
1085            .map_err(|e| JsValue::from_str(&e.to_string()))
1086    }
1087}
1088
1089#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1090#[wasm_bindgen]
1091pub fn accumulation_swing_index_batch_into(
1092    open_ptr: *const f64,
1093    high_ptr: *const f64,
1094    low_ptr: *const f64,
1095    close_ptr: *const f64,
1096    out_ptr: *mut f64,
1097    len: usize,
1098    daily_limit_start: f64,
1099    daily_limit_end: f64,
1100    daily_limit_step: f64,
1101) -> Result<usize, JsValue> {
1102    if open_ptr.is_null()
1103        || high_ptr.is_null()
1104        || low_ptr.is_null()
1105        || close_ptr.is_null()
1106        || out_ptr.is_null()
1107    {
1108        return Err(JsValue::from_str(
1109            "null pointer passed to accumulation_swing_index_batch_into",
1110        ));
1111    }
1112    unsafe {
1113        let open = std::slice::from_raw_parts(open_ptr, len);
1114        let high = std::slice::from_raw_parts(high_ptr, len);
1115        let low = std::slice::from_raw_parts(low_ptr, len);
1116        let close = std::slice::from_raw_parts(close_ptr, len);
1117        let sweep = AccumulationSwingIndexBatchRange {
1118            daily_limit: (daily_limit_start, daily_limit_end, daily_limit_step),
1119        };
1120        let combos = expand_grid(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
1121        let rows = combos.len();
1122        let total = rows.checked_mul(len).ok_or_else(|| {
1123            JsValue::from_str("rows*cols overflow in accumulation_swing_index_batch_into")
1124        })?;
1125        let out = std::slice::from_raw_parts_mut(out_ptr, total);
1126        accumulation_swing_index_batch_into_slice(
1127            out,
1128            open,
1129            high,
1130            low,
1131            close,
1132            &sweep,
1133            Kernel::Auto,
1134        )
1135        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1136        Ok(rows)
1137    }
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142    use super::*;
1143
1144    fn manual_accumulation_swing_index(
1145        open: &[f64],
1146        high: &[f64],
1147        low: &[f64],
1148        close: &[f64],
1149        daily_limit: f64,
1150    ) -> Vec<f64> {
1151        let n = close.len();
1152        let mut out = vec![f64::NAN; n];
1153        let first = first_valid_ohlc(open, high, low, close).unwrap();
1154        compute_accumulation_swing_index_into(open, high, low, close, daily_limit, first, &mut out);
1155        out
1156    }
1157
1158    fn sample_ohlc(n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
1159        let open: Vec<f64> = (0..n)
1160            .map(|i| 100.0 + ((i as f64) * 0.17).sin() * 1.4 + (i as f64) * 0.02)
1161            .collect();
1162        let close: Vec<f64> = open
1163            .iter()
1164            .enumerate()
1165            .map(|(i, &o)| o + ((i as f64) * 0.23).cos() * 0.9)
1166            .collect();
1167        let high: Vec<f64> = open
1168            .iter()
1169            .zip(close.iter())
1170            .enumerate()
1171            .map(|(i, (&o, &c))| o.max(c) + 0.8 + ((i as f64) * 0.07).sin().abs())
1172            .collect();
1173        let low: Vec<f64> = open
1174            .iter()
1175            .zip(close.iter())
1176            .enumerate()
1177            .map(|(i, (&o, &c))| o.min(c) - 0.7 - ((i as f64) * 0.11).cos().abs())
1178            .collect();
1179        (open, high, low, close)
1180    }
1181
1182    fn assert_close(lhs: &[f64], rhs: &[f64]) {
1183        assert_eq!(lhs.len(), rhs.len());
1184        for (idx, (&a, &b)) in lhs.iter().zip(rhs.iter()).enumerate() {
1185            if a.is_nan() && b.is_nan() {
1186                continue;
1187            }
1188            let diff = (a - b).abs();
1189            assert!(diff <= 1e-12, "mismatch at {idx}: {a} vs {b}");
1190        }
1191    }
1192
1193    #[test]
1194    fn manual_reference_matches_api() {
1195        let (open, high, low, close) = sample_ohlc(128);
1196        let input = AccumulationSwingIndexInput::from_slices(
1197            &open,
1198            &high,
1199            &low,
1200            &close,
1201            AccumulationSwingIndexParams {
1202                daily_limit: Some(10_000.0),
1203            },
1204        );
1205        let out = accumulation_swing_index(&input).unwrap();
1206        let want = manual_accumulation_swing_index(&open, &high, &low, &close, 10_000.0);
1207        assert_close(&out.values, &want);
1208    }
1209
1210    #[test]
1211    fn stream_matches_batch() {
1212        let (open, high, low, close) = sample_ohlc(96);
1213        let input = AccumulationSwingIndexInput::from_slices(
1214            &open,
1215            &high,
1216            &low,
1217            &close,
1218            AccumulationSwingIndexParams {
1219                daily_limit: Some(10_000.0),
1220            },
1221        );
1222        let out = accumulation_swing_index(&input).unwrap();
1223        let mut stream = AccumulationSwingIndexStream::try_new(AccumulationSwingIndexParams {
1224            daily_limit: Some(10_000.0),
1225        })
1226        .unwrap();
1227        let mut got = Vec::with_capacity(open.len());
1228        for i in 0..open.len() {
1229            got.push(stream.update(open[i], high[i], low[i], close[i]));
1230        }
1231        assert_close(&out.values, &got);
1232    }
1233
1234    #[test]
1235    fn batch_first_row_matches_single() {
1236        let (open, high, low, close) = sample_ohlc(80);
1237        let batch = accumulation_swing_index_batch_with_kernel(
1238            &open,
1239            &high,
1240            &low,
1241            &close,
1242            &AccumulationSwingIndexBatchRange {
1243                daily_limit: (10_000.0, 12_000.0, 2_000.0),
1244            },
1245            Kernel::Auto,
1246        )
1247        .unwrap();
1248        let input = AccumulationSwingIndexInput::from_slices(
1249            &open,
1250            &high,
1251            &low,
1252            &close,
1253            AccumulationSwingIndexParams {
1254                daily_limit: Some(10_000.0),
1255            },
1256        );
1257        let single = accumulation_swing_index(&input).unwrap();
1258        assert_eq!(batch.rows, 2);
1259        assert_close(&batch.values[..80], single.values.as_slice());
1260    }
1261
1262    #[test]
1263    fn into_slice_matches_single() {
1264        let (open, high, low, close) = sample_ohlc(72);
1265        let input = AccumulationSwingIndexInput::from_slices(
1266            &open,
1267            &high,
1268            &low,
1269            &close,
1270            AccumulationSwingIndexParams {
1271                daily_limit: Some(10_000.0),
1272            },
1273        );
1274        let single = accumulation_swing_index(&input).unwrap();
1275        let mut out = vec![0.0; close.len()];
1276        accumulation_swing_index_into_slice(&mut out, &input, Kernel::Auto).unwrap();
1277        assert_close(&single.values, &out);
1278    }
1279
1280    #[test]
1281    fn invalid_daily_limit_is_rejected() {
1282        let (open, high, low, close) = sample_ohlc(32);
1283        let input = AccumulationSwingIndexInput::from_slices(
1284            &open,
1285            &high,
1286            &low,
1287            &close,
1288            AccumulationSwingIndexParams {
1289                daily_limit: Some(0.0),
1290            },
1291        );
1292        assert!(matches!(
1293            accumulation_swing_index(&input),
1294            Err(AccumulationSwingIndexError::InvalidDailyLimit { .. })
1295        ));
1296    }
1297}