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}