Files
ferro-ta/crates/ferro_ta_core/src/overlap.rs
T
Pratik BhadaneandGitHub 288b1546b2 feat: broaden CPU and platform coverage across PyPI, nodes, and crate… (#26)
* feat: broaden CPU and platform coverage across PyPI, nodes, and crates.io

Replace static SIMD with runtime CPU-feature dispatch and expand the release
wheel matrix so one set of artifacts runs on any target CPU and platform
without illegal-instruction crashes.

Rust core:
- Add multiversion runtime dispatch (crates/ferro_ta_core/src/simd.rs); drop
  compile-time `wide`. `simd` feature is now default-on and forwarded through
  the pyo3 crate, and stays compatible with #![forbid(unsafe_code)].

Packaging:
- abi3-py310: one cp310-abi3 wheel per platform (covers CPython 3.10+).
- CI matrix adds Linux aarch64 + musllinux (x86_64/aarch64) and Windows arm64.

Node/Docker + docs:
- api/Dockerfile: document baseline+dispatch (no target-cpu pin) and add a
  fail-fast import check; aarch64 containers now install cleanly.
- Rewrite docs/guides/simd.md; fix stale `wide` mention in ADR 0003.
- Add ADR 0006 (CPU coverage strategy).

Also bundles in-flight release prep already staged in the tree (DTW exception
types, SBOM/provenance security, supporting docs).

* fix(ci): clear cargo-deny and pip-audit failures; apply dependency bumps

cargo-deny (advisories):
- Ignore pyo3 RUSTSEC-2026-0176 / RUSTSEC-2026-0177 in deny.toml with a
  documented rationale: ferro-ta uses neither affected code path
  (PyList/PyTuple nth iterators; PyCFunction::new_closure). Upstream fix
  needs pyo3 >=0.29 (large API migration), tracked as a follow-up.

pip-audit:
- Bump dev lockfile idna 3.18, pytest 9.1.1, urllib3 2.7.0 to clear
  PYSEC-2026-215, CVE-2025-71176, PYSEC-2026-141/142.

Dependency bumps (supersede open dependabot PRs; they auto-close on merge):
- cargo: log 0.4.32, serde_json 1.0.150, rayon 1.12.0
- api/requirements.txt: uvicorn>=0.49.0, pydantic>=2.13.4, ferro-ta>=1.1.4
- CI actions: deploy-pages v5, upload-pages-artifact v5, action-gh-release v3

The open `wide` 1.5.0 bump (PR #24) is obsolete — the crate is removed in
this branch.

* chore: address CodeRabbit review; remove docs/adr section

CodeRabbit findings:
- CI sbom job: add `attestations: write` so attest-build-provenance can run
  (it had only contents:write + id-token:write).
- simd.rs: vectorize `wma_seed` with lane-local accumulators — it was scalar
  behind the multiversion wrapper, adding dispatch overhead for no SIMD gain.
- CHANGELOG: consolidate the duplicate `### Changed` heading.
- python/ferro_ta/__init__.py: also re-export the `FerroTaError` alias.
- docs/guides/dtw.md: soften "byte-for-byte" parity to within-tolerance.

Remove docs/adr/ at maintainer request and clean up the ADR links in the
SIMD and DTW guides. The ADR files remain in commit 9506a30 if ever needed.
2026-06-29 18:21:22 +05:30

1055 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Overlap studies — moving averages and trend indicators.
//!
//! All functions return a `Vec<f64>` of the same length as the input.
//! Leading values are `f64::NAN` for the warm-up period.
/// Compute the Simple Moving Average (SMA) over a rolling window.
///
/// Returns a `Vec<f64>` of the same length as `close`. The first
/// `timeperiod - 1` values are `NaN` (warmup period).
///
/// # Arguments
/// * `close` - Price series.
/// * `timeperiod` - Rolling window size (must be >= 1).
///
/// # Edge Cases
/// Returns all-NaN when `timeperiod < 1` or `close.len() < timeperiod`.
pub fn sma(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
sma_into(close, timeperiod, &mut result, 0);
result
}
/// Write a Simple Moving Average directly into a pre-allocated buffer.
///
/// Values before `dest_offset + timeperiod - 1` are left untouched.
/// This avoids an intermediate allocation when composing indicators
/// (e.g., Stochastic slow %K and slow %D).
///
/// # Arguments
/// * `src` - Input price series.
/// * `timeperiod` - Rolling window size (must be >= 1).
/// * `dest` - Output buffer (must be at least `dest_offset + src.len()` long).
/// * `dest_offset` - Starting index in `dest` to write results.
pub fn sma_into(src: &[f64], timeperiod: usize, dest: &mut [f64], dest_offset: usize) {
let n = src.len();
if timeperiod < 1 || n < timeperiod {
return;
}
// Seed the rolling window with a runtime-dispatched reduction. The O(n)
// streaming recurrence below is inherently sequential, so SIMD only ever
// applies to this initial window sum.
let mut window_sum = crate::simd::sum(&src[..timeperiod]);
let tp_f64 = timeperiod as f64;
dest[dest_offset + timeperiod - 1] = window_sum / tp_f64;
let mut i = timeperiod;
while i + 1 < n {
let old0 = src[i - timeperiod];
let new0 = src[i];
window_sum += new0 - old0;
dest[dest_offset + i] = window_sum / tp_f64;
let old1 = src[i + 1 - timeperiod];
let new1 = src[i + 1];
window_sum += new1 - old1;
dest[dest_offset + i + 1] = window_sum / tp_f64;
i += 2;
}
if i < n {
window_sum += src[i] - src[i - timeperiod];
dest[dest_offset + i] = window_sum / tp_f64;
}
}
/// Compute the Exponential Moving Average (EMA).
///
/// The EMA is seeded with the SMA of the first `timeperiod` bars and uses
/// a smoothing factor of `k = 2 / (timeperiod + 1)`. Returns a `Vec<f64>`
/// of the same length as `close`; the first `timeperiod - 1` values are `NaN`.
///
/// # Arguments
/// * `close` - Price series.
/// * `timeperiod` - Lookback period (must be >= 1).
pub fn ema(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod < 1 || n < timeperiod {
return result;
}
let k = 2.0 / (timeperiod as f64 + 1.0);
let seed: f64 = close[..timeperiod].iter().sum::<f64>() / timeperiod as f64;
result[timeperiod - 1] = seed;
for i in timeperiod..n {
result[i] = (result[i - 1] * (1.0 - k)).mul_add(1.0, close[i] * k);
}
result
}
/// Compute the Weighted Moving Average (WMA).
///
/// Assigns linearly increasing weights (1, 2, ..., timeperiod) to the window.
/// Uses an O(n) incremental recurrence to avoid recomputing weights each bar.
/// Returns a `Vec<f64>` of length `n`; the first `timeperiod - 1` values are `NaN`.
///
/// # Arguments
/// * `close` - Price series.
/// * `timeperiod` - Rolling window size (must be >= 1).
pub fn wma(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod < 1 || n < timeperiod {
return result;
}
let denom: f64 = (timeperiod * (timeperiod + 1) / 2) as f64;
let p = timeperiod as f64;
// Seed: compute T and S for the first window via a runtime-dispatched
// reduction (the streaming recurrence below is sequential).
let (mut t, mut s) = crate::simd::wma_seed(&close[..timeperiod]);
result[timeperiod - 1] = t / denom;
let mut i = timeperiod;
while i + 1 < n {
t += p * close[i] - s;
s += close[i] - close[i - timeperiod];
result[i] = t / denom;
t += p * close[i + 1] - s;
s += close[i + 1] - close[i + 1 - timeperiod];
result[i + 1] = t / denom;
i += 2;
}
if i < n {
t += p * close[i] - s;
result[i] = t / denom;
}
result
}
/// Compute Bollinger Bands, returning `(upper, middle, lower)`.
///
/// The middle band is the SMA; upper and lower bands are offset by
/// `nbdevup` and `nbdevdn` standard deviations respectively. Uses
/// Welford's rolling algorithm for numerically stable variance in O(n).
///
/// # Arguments
/// * `close` - Price series.
/// * `timeperiod` - SMA / standard deviation window (must be >= 1).
/// * `nbdevup` - Number of standard deviations above the mean for the upper band.
/// * `nbdevdn` - Number of standard deviations below the mean for the lower band.
///
/// # Returns
/// `(upper, middle, lower)` -- each `Vec<f64>` of length `n`. The first
/// `timeperiod - 1` values in each vector are `NaN`.
///
/// ## Welford's rolling algorithm
///
/// We maintain `mean` and `m2` (sum of squared deviations from the current
/// mean) across a sliding window of size `N`. When a new value `x_new`
/// replaces an old value `x_old` (window size stays constant):
///
/// ```text
/// delta = x_new - x_old
/// old_mean = mean
/// mean += delta / N
/// m2 += delta * ((x_new - mean) + (x_old - old_mean))
///
/// variance = m2 / N // population variance
/// stddev = sqrt(variance)
/// ```
///
/// The initial window is seeded using the standard (non-rolling) Welford
/// incremental algorithm.
///
/// This avoids the catastrophic cancellation inherent in the naïve
/// `Σx²/N mean²` formula when values are large but close together.
pub fn bbands(
close: &[f64],
timeperiod: usize,
nbdevup: f64,
nbdevdn: f64,
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let n = close.len();
let nan = vec![f64::NAN; n];
if timeperiod < 1 || n < timeperiod {
return (nan.clone(), nan.clone(), nan);
}
let mut upper = vec![f64::NAN; n];
let mut middle = vec![f64::NAN; n];
let mut lower = vec![f64::NAN; n];
let p = timeperiod as f64;
// --- Seed: build initial mean and m2 for the first window using
// Welford's incremental (non-rolling) algorithm. ---
let mut mean = 0.0_f64;
let mut m2 = 0.0_f64;
for (k, &x) in close[..timeperiod].iter().enumerate() {
let count = (k + 1) as f64;
let delta = x - mean;
mean += delta / count;
let delta2 = x - mean;
m2 += delta * delta2;
}
let var = (m2 / p).max(0.0);
let std = var.sqrt();
middle[timeperiod - 1] = mean;
upper[timeperiod - 1] = mean + nbdevup * std;
lower[timeperiod - 1] = mean - nbdevdn * std;
// --- Rolling phase: slide the window one element at a time,
// removing the oldest value and adding the newest. ---
/// Inline helper: replace `x_old` with `x_new` in the Welford accumulator
/// (constant window size `p`), then write band values into the output slots.
///
/// Combined rolling Welford update (window size stays constant at N):
///
/// ```text
/// delta = x_new - x_old
/// old_mean = mean
/// mean += delta / N
/// m2 += delta * ((x_new - mean) + (x_old - old_mean))
/// ```
///
/// This is algebraically equivalent to removing `x_old` and adding `x_new`
/// in two separate Welford steps, but avoids the intermediate N-1 state.
#[inline(always)]
#[allow(clippy::too_many_arguments)]
fn welford_step(
x_old: f64,
x_new: f64,
mean: &mut f64,
m2: &mut f64,
p: f64,
nbdevup: f64,
nbdevdn: f64,
upper: &mut f64,
middle: &mut f64,
lower: &mut f64,
) {
let delta = x_new - x_old;
let old_mean = *mean;
*mean += delta / p;
// Update m2 using both old and new deviations.
*m2 += delta * ((x_new - *mean) + (x_old - old_mean));
// Clamp m2 to zero to guard against floating-point drift.
if *m2 < 0.0 {
*m2 = 0.0;
}
let var = *m2 / p;
let std = var.sqrt();
*middle = *mean;
*upper = *mean + nbdevup * std;
*lower = *mean - nbdevdn * std;
}
// Process two iterations at a time (loop unrolling) for throughput.
let mut i = timeperiod;
while i + 1 < n {
welford_step(
close[i - timeperiod],
close[i],
&mut mean,
&mut m2,
p,
nbdevup,
nbdevdn,
&mut upper[i],
&mut middle[i],
&mut lower[i],
);
welford_step(
close[i + 1 - timeperiod],
close[i + 1],
&mut mean,
&mut m2,
p,
nbdevup,
nbdevdn,
&mut upper[i + 1],
&mut middle[i + 1],
&mut lower[i + 1],
);
i += 2;
}
if i < n {
welford_step(
close[i - timeperiod],
close[i],
&mut mean,
&mut m2,
p,
nbdevup,
nbdevdn,
&mut upper[i],
&mut middle[i],
&mut lower[i],
);
}
(upper, middle, lower)
}
/// Compute the Moving Average Convergence/Divergence (MACD).
///
/// `MACD = EMA(close, fastperiod) - EMA(close, slowperiod)`.
/// The signal line is `EMA(macd, signalperiod)` and the histogram is
/// `macd - signal`. TA-Lib compatible: leading values are `NaN` up to
/// the point where all three outputs are valid.
///
/// # Arguments
/// * `close` - Price series.
/// * `fastperiod` - Fast EMA period (must be < `slowperiod`).
/// * `slowperiod` - Slow EMA period.
/// * `signalperiod` - Signal line EMA period.
///
/// # Returns
/// `(macd_line, signal_line, histogram)` -- each `Vec<f64>` of length `n`.
pub fn macd(
close: &[f64],
fastperiod: usize,
slowperiod: usize,
signalperiod: usize,
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let n = close.len();
let nan_vec = || vec![f64::NAN; n];
if fastperiod < 1 || slowperiod < 1 || signalperiod < 1 || fastperiod >= slowperiod {
return (nan_vec(), nan_vec(), nan_vec());
}
if n < slowperiod {
return (nan_vec(), nan_vec(), nan_vec());
}
let kf = 2.0 / (fastperiod as f64 + 1.0);
let ks = 2.0 / (slowperiod as f64 + 1.0);
// Seed fast EMA from SMA of first fastperiod bars.
let mut fast_val: f64 = close[..fastperiod].iter().sum::<f64>() / fastperiod as f64;
// Seed slow EMA from SMA of first slowperiod bars.
let mut slow_val: f64 = close[..slowperiod].iter().sum::<f64>() / slowperiod as f64;
let mut macd_line = nan_vec();
// From fastperiod-1 to slowperiod-2: advance fast EMA only.
for &price in close.iter().take(slowperiod - 1).skip(fastperiod) {
fast_val = price * kf + fast_val * (1.0 - kf);
}
// From fastperiod to slowperiod-1: advance fastEMA and compute initial MACD at slowperiod-1
// Actually, fast_val currently holds the value for `slowperiod - 2` after `take(slowperiod - 1)`
// So we apply it for `slowperiod - 1`.
fast_val = close[slowperiod - 1] * kf + fast_val * (1.0 - kf);
macd_line[slowperiod - 1] = fast_val - slow_val;
for i in slowperiod..n {
fast_val = close[i] * kf + fast_val * (1.0 - kf);
slow_val = close[i] * ks + slow_val * (1.0 - ks);
macd_line[i] = fast_val - slow_val;
}
// Signal line: EMA of macd_line, seeded from the first valid macd value.
// The signal line starts producing values after slowperiod - 1 + signalperiod - 1 bars.
let sig_start = slowperiod - 1 + signalperiod - 1;
let mut signal_line = nan_vec();
let mut histogram = nan_vec();
if sig_start >= n {
// If we can't compute signal, TA-Lib clears MACD!
for v in macd_line.iter_mut().take(n) {
*v = f64::NAN;
}
return (macd_line, signal_line, histogram);
}
let ksig = 2.0 / (signalperiod as f64 + 1.0);
// Seed signal EMA with SMA of the first signalperiod macd values.
let sig_seed: f64 = macd_line[(slowperiod - 1)..(slowperiod - 1 + signalperiod)]
.iter()
.sum::<f64>()
/ signalperiod as f64;
signal_line[sig_start] = sig_seed;
histogram[sig_start] = macd_line[sig_start] - signal_line[sig_start];
for i in (sig_start + 1)..n {
signal_line[i] = macd_line[i] * ksig + signal_line[i - 1] * (1.0 - ksig);
}
for i in (sig_start + 1)..n {
histogram[i] = macd_line[i] - signal_line[i];
}
// TA-Lib pads the MACD line itself with NaNs up to `sig_start`!
for v in macd_line.iter_mut().take(sig_start) {
*v = f64::NAN;
}
(macd_line, signal_line, histogram)
}
// ---------------------------------------------------------------------------
// DEMA — Double Exponential Moving Average
// ---------------------------------------------------------------------------
/// Double Exponential Moving Average: `2*EMA - EMA(EMA)`.
pub fn dema(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
let warmup = 2 * (timeperiod - 1);
let ema1 = ema(close, timeperiod);
let ema2 = ema(&ema1, timeperiod);
for i in warmup..n {
if !ema1[i].is_nan() && !ema2[i].is_nan() {
result[i] = 2.0 * ema1[i] - ema2[i];
}
}
result
}
// ---------------------------------------------------------------------------
// TEMA — Triple Exponential Moving Average
// ---------------------------------------------------------------------------
/// Triple Exponential Moving Average: `3*EMA - 3*EMA(EMA) + EMA(EMA(EMA))`.
pub fn tema(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
let warmup = 3 * (timeperiod - 1);
let ema1 = ema(close, timeperiod);
let ema2 = ema(&ema1, timeperiod);
let ema3 = ema(&ema2, timeperiod);
for i in warmup..n {
if !ema1[i].is_nan() && !ema2[i].is_nan() && !ema3[i].is_nan() {
result[i] = 3.0 * ema1[i] - 3.0 * ema2[i] + ema3[i];
}
}
result
}
// ---------------------------------------------------------------------------
// TRIMA — Triangular Moving Average
// ---------------------------------------------------------------------------
/// Triangular Moving Average (triangle-weighted).
pub fn trima(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let half = timeperiod.div_ceil(2);
let mut weights = Vec::with_capacity(timeperiod);
for i in 1..=timeperiod {
let w = if i <= half { i } else { timeperiod + 1 - i };
weights.push(w as f64);
}
let weight_sum: f64 = weights.iter().sum();
for i in (timeperiod - 1)..n {
let mut val = 0.0_f64;
for (j, &w) in weights.iter().enumerate() {
val += close[i - (timeperiod - 1 - j)] * w;
}
result[i] = val / weight_sum;
}
result
}
// ---------------------------------------------------------------------------
// KAMA — Kaufman Adaptive Moving Average
// ---------------------------------------------------------------------------
/// Kaufman Adaptive Moving Average.
pub fn kama(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let fast_sc = 2.0 / 3.0_f64;
let slow_sc = 2.0 / 31.0_f64;
let mut kama_val = close[timeperiod - 1];
result[timeperiod - 1] = kama_val;
for i in timeperiod..n {
let direction = (close[i] - close[i - timeperiod]).abs();
let mut volatility = 0.0_f64;
for j in 1..=timeperiod {
volatility += (close[i - j + 1] - close[i - j]).abs();
}
let er = if volatility > 0.0 {
direction / volatility
} else {
0.0
};
let sc = (er * (fast_sc - slow_sc) + slow_sc).powi(2);
kama_val += sc * (close[i] - kama_val);
result[i] = kama_val;
}
result
}
// ---------------------------------------------------------------------------
// T3 — Tillson T3
// ---------------------------------------------------------------------------
/// Tillson T3: 6x smoothed EMA with volume factor.
pub fn t3(close: &[f64], timeperiod: usize, vfactor: f64) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
let k = 2.0 / (timeperiod as f64 + 1.0);
let v = vfactor;
let c1 = -(v * v * v);
let c2 = 3.0 * v * v + 3.0 * v * v * v;
let c3 = -6.0 * v * v - 3.0 * v - 3.0 * v * v * v;
let c4 = 1.0 + 3.0 * v + v * v * v + 3.0 * v * v;
let warmup = 6 * (timeperiod - 1);
let mut e = [0.0_f64; 6];
for (i, &price) in close.iter().enumerate() {
if i == 0 {
for ej in e.iter_mut() {
*ej = price;
}
} else {
e[0] += k * (price - e[0]);
for j in 1..6 {
e[j] += k * (e[j - 1] - e[j]);
}
}
if i >= warmup {
result[i] = c1 * e[5] + c2 * e[4] + c3 * e[3] + c4 * e[2];
}
}
result
}
// ---------------------------------------------------------------------------
// SAR — Parabolic SAR
// ---------------------------------------------------------------------------
/// Parabolic SAR.
pub fn sar(high: &[f64], low: &[f64], acceleration: f64, maximum: f64) -> Vec<f64> {
let n = high.len();
if n < 2 {
return vec![f64::NAN; n];
}
let mut result = vec![f64::NAN; n];
let mut is_rising = high[1] >= high[0];
let mut af = acceleration;
let (mut ep, mut sar_val) = if is_rising {
(high[1], low[0])
} else {
(low[1], high[0])
};
result[1] = sar_val;
for i in 2..n {
let prev_sar = sar_val;
sar_val = prev_sar + af * (ep - prev_sar);
if is_rising {
sar_val = sar_val.min(low[i - 1]).min(low[i - 2]);
if low[i] < sar_val {
is_rising = false;
sar_val = ep;
ep = low[i];
af = acceleration;
} else if high[i] > ep {
ep = high[i];
af = (af + acceleration).min(maximum);
}
} else {
sar_val = sar_val.max(high[i - 1]).max(high[i - 2]);
if high[i] > sar_val {
is_rising = true;
sar_val = ep;
ep = high[i];
af = acceleration;
} else if low[i] < ep {
ep = low[i];
af = (af + acceleration).min(maximum);
}
}
result[i] = sar_val;
}
result
}
// ---------------------------------------------------------------------------
// SAREXT — Extended Parabolic SAR
// ---------------------------------------------------------------------------
/// Parabolic SAR Extended with configurable acceleration factors.
#[allow(clippy::too_many_arguments)]
pub fn sarext(
high: &[f64],
low: &[f64],
startvalue: f64,
offsetonreverse: f64,
accelerationinitlong: f64,
accelerationlong: f64,
accelerationmaxlong: f64,
accelerationinitshort: f64,
accelerationshort: f64,
accelerationmaxshort: f64,
) -> Vec<f64> {
let n = high.len();
if n < 2 {
return vec![f64::NAN; n];
}
let mut result = vec![f64::NAN; n];
let mut is_rising = high[1] >= high[0];
let (mut af, mut af_step_cur, mut af_max_cur) = if is_rising {
(accelerationinitlong, accelerationlong, accelerationmaxlong)
} else {
(
accelerationinitshort,
accelerationshort,
accelerationmaxshort,
)
};
let (mut ep, mut sar_val) = if is_rising {
(
high[1],
if startvalue != 0.0 {
startvalue
} else {
low[0]
},
)
} else {
(
low[1],
if startvalue != 0.0 {
-startvalue
} else {
high[0]
},
)
};
result[1] = sar_val;
for i in 2..n {
let prev_sar = sar_val;
sar_val = prev_sar + af * (ep - prev_sar);
if is_rising {
sar_val = sar_val.min(low[i - 1]).min(low[i - 2]);
if low[i] < sar_val {
is_rising = false;
sar_val = ep + sar_val.abs() * offsetonreverse;
ep = low[i];
af = accelerationinitshort;
af_step_cur = accelerationshort;
af_max_cur = accelerationmaxshort;
} else if high[i] > ep {
ep = high[i];
af = (af + af_step_cur).min(af_max_cur);
}
} else {
sar_val = sar_val.max(high[i - 1]).max(high[i - 2]);
if high[i] > sar_val {
is_rising = true;
sar_val = ep - sar_val.abs() * offsetonreverse;
ep = high[i];
af = accelerationinitlong;
af_step_cur = accelerationlong;
af_max_cur = accelerationmaxlong;
} else if low[i] < ep {
ep = low[i];
af = (af + af_step_cur).min(af_max_cur);
}
}
result[i] = sar_val;
}
result
}
// ---------------------------------------------------------------------------
// MAMA — MESA Adaptive Moving Average
// ---------------------------------------------------------------------------
/// MESA Adaptive Moving Average. Returns `(mama, fama)`.
pub fn mama(close: &[f64], fastlimit: f64, slowlimit: f64) -> (Vec<f64>, Vec<f64>) {
let n = close.len();
let lookback = 32;
let mut mama_arr = vec![f64::NAN; n];
let mut fama_arr = vec![f64::NAN; n];
if n <= lookback {
return (mama_arr, fama_arr);
}
let mut smooth = vec![0.0f64; n];
for i in 0..n {
smooth[i] = if i >= 3 {
(4.0 * close[i] + 3.0 * close[i - 1] + 2.0 * close[i - 2] + close[i - 3]) / 10.0
} else {
close[i]
};
}
let mut detrender = vec![0.0f64; n];
let mut q1 = vec![0.0f64; n];
let mut i1 = vec![0.0f64; n];
let mut ji = vec![0.0f64; n];
let mut jq = vec![0.0f64; n];
let mut i2 = vec![0.0f64; n];
let mut q2 = vec![0.0f64; n];
let mut re = vec![0.0f64; n];
let mut im = vec![0.0f64; n];
let mut period = vec![0.0f64; n];
let mut phase = vec![0.0f64; n];
let mut mama_val = close[0];
let mut fama_val = close[0];
for i in 6..n {
let prev_period = period[i - 1].max(1.0);
let alpha = 0.075 * prev_period + 0.54;
detrender[i] = (0.0962 * smooth[i] + 0.5769 * smooth[i - 2]
- 0.5769 * smooth[i - 4]
- 0.0962 * smooth[i - 6])
* alpha;
if i >= 12 {
q1[i] = (0.0962 * detrender[i] + 0.5769 * detrender[i - 2]
- 0.5769 * detrender[i - 4]
- 0.0962 * detrender[i - 6])
* alpha;
}
if i >= 9 {
i1[i] = detrender[i - 3];
}
if i >= 15 {
ji[i] = (0.0962 * i1[i] + 0.5769 * i1[i - 2] - 0.5769 * i1[i - 4] - 0.0962 * i1[i - 6])
* alpha;
}
if i >= 18 {
jq[i] = (0.0962 * q1[i] + 0.5769 * q1[i - 2] - 0.5769 * q1[i - 4] - 0.0962 * q1[i - 6])
* alpha;
}
let i2_raw = i1[i] - jq[i];
let q2_raw = q1[i] + ji[i];
i2[i] = 0.2 * i2_raw + 0.8 * i2[i - 1];
q2[i] = 0.2 * q2_raw + 0.8 * q2[i - 1];
re[i] = 0.2 * (i2[i] * i2[i - 1] + q2[i] * q2[i - 1]) + 0.8 * re[i - 1];
im[i] = 0.2 * (i2[i] * q2[i - 1] - q2[i] * i2[i - 1]) + 0.8 * im[i - 1];
let mut p = if re[i] != 0.0 && im[i] != 0.0 && re[i] > 0.0 {
std::f64::consts::PI * 2.0 / (im[i] / re[i]).atan()
} else {
prev_period
};
p = p
.clamp(0.67 * prev_period, 1.5 * prev_period)
.clamp(6.0, 50.0);
period[i] = 0.2 * p + 0.8 * prev_period;
phase[i] = if i1[i] != 0.0 {
q1[i].atan2(i1[i]) * 180.0 / std::f64::consts::PI
} else if q1[i] > 0.0 {
90.0
} else if q1[i] < 0.0 {
-90.0
} else {
0.0
};
let mut delta_phase = phase[i - 1] - phase[i];
if delta_phase < 1.0 {
delta_phase = 1.0;
}
let adaptive_alpha = (fastlimit / delta_phase).clamp(slowlimit, fastlimit);
if i >= lookback {
mama_val = adaptive_alpha * close[i] + (1.0 - adaptive_alpha) * mama_val;
fama_val = 0.5 * adaptive_alpha * mama_val + (1.0 - 0.5 * adaptive_alpha) * fama_val;
mama_arr[i] = mama_val;
fama_arr[i] = fama_val;
} else {
mama_val = close[i];
fama_val = close[i];
}
}
(mama_arr, fama_arr)
}
// ---------------------------------------------------------------------------
// MIDPOINT / MIDPRICE
// ---------------------------------------------------------------------------
/// Midpoint: `(max(close) + min(close)) / 2` over rolling window.
pub fn midpoint(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
for i in (timeperiod - 1)..n {
let window = &close[(i + 1 - timeperiod)..=i];
let mx = window.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let mn = window.iter().cloned().fold(f64::INFINITY, f64::min);
result[i] = (mx + mn) / 2.0;
}
result
}
/// MidPrice: `(highest_high + lowest_low) / 2` over rolling window.
pub fn midprice(high: &[f64], low: &[f64], timeperiod: usize) -> Vec<f64> {
let n = high.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
for i in (timeperiod - 1)..n {
let start = i + 1 - timeperiod;
let mx = high[start..=i]
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
let mn = low[start..=i].iter().cloned().fold(f64::INFINITY, f64::min);
result[i] = (mx + mn) / 2.0;
}
result
}
// ---------------------------------------------------------------------------
// MACDFIX / MACDEXT
// ---------------------------------------------------------------------------
/// MACD with fixed 12/26 periods.
pub fn macdfix(close: &[f64], signalperiod: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
macd(close, 12, 26, signalperiod)
}
/// Compute MA by type: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3.
fn compute_ma_by_type(close: &[f64], timeperiod: usize, matype: u8) -> Vec<f64> {
match matype {
0 => sma(close, timeperiod),
1 => ema(close, timeperiod),
2 => wma(close, timeperiod),
3 => dema(close, timeperiod),
4 => tema(close, timeperiod),
5 => trima(close, timeperiod),
6 => kama(close, timeperiod),
7 => t3(close, timeperiod, 0.7),
_ => sma(close, timeperiod),
}
}
/// MACD with configurable MA types for fast/slow/signal.
pub fn macdext(
close: &[f64],
fastperiod: usize,
fastmatype: u8,
slowperiod: usize,
slowmatype: u8,
signalperiod: usize,
signalmatype: u8,
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let n = close.len();
let nan3 = || (vec![f64::NAN; n], vec![f64::NAN; n], vec![f64::NAN; n]);
if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 || fastperiod >= slowperiod {
return nan3();
}
let fast_ma = compute_ma_by_type(close, fastperiod, fastmatype);
let slow_ma = compute_ma_by_type(close, slowperiod, slowmatype);
let macd_start = slowperiod - 1;
let mut macd_line = vec![f64::NAN; n];
for i in macd_start..n {
if !fast_ma[i].is_nan() && !slow_ma[i].is_nan() {
macd_line[i] = fast_ma[i] - slow_ma[i];
}
}
let macd_valid: Vec<f64> = macd_line[macd_start..].to_vec();
let signal_slice = compute_ma_by_type(&macd_valid, signalperiod, signalmatype);
let mut signal_line = vec![f64::NAN; n];
let warmup = macd_start + signalperiod - 1;
#[allow(clippy::needless_range_loop)]
for i in warmup..n {
let j = i - macd_start;
if j < signal_slice.len() && !signal_slice[j].is_nan() {
signal_line[i] = signal_slice[j];
}
}
let mut histogram = vec![f64::NAN; n];
for i in 0..n {
if !macd_line[i].is_nan() && !signal_line[i].is_nan() {
histogram[i] = macd_line[i] - signal_line[i];
}
}
(macd_line, signal_line, histogram)
}
// ---------------------------------------------------------------------------
// MA (generic dispatcher) / MAVP (variable period)
// ---------------------------------------------------------------------------
/// Generic Moving Average. matype: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3.
pub fn ma(close: &[f64], timeperiod: usize, matype: u8) -> Vec<f64> {
compute_ma_by_type(close, timeperiod, matype)
}
/// Moving Average with Variable Period per bar (SMA over period from periods array).
pub fn mavp(close: &[f64], periods: &[f64], minperiod: usize, maxperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if minperiod == 0 || maxperiod < minperiod {
return result;
}
for i in 0..n {
if i >= periods.len() {
break;
}
let p = (periods[i].round() as usize).clamp(minperiod, maxperiod);
if i + 1 >= p {
let sum: f64 = close[(i + 1 - p)..=i].iter().sum();
result[i] = sum / p as f64;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sma_basic() {
let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = sma(&prices, 3);
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!((result[2] - 2.0).abs() < 1e-10);
assert!((result[3] - 3.0).abs() < 1e-10);
assert!((result[4] - 4.0).abs() < 1e-10);
}
#[test]
fn ema_basic() {
let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = ema(&prices, 3);
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!((result[2] - 2.0).abs() < 1e-10); // seed = SMA(3)
}
#[test]
fn wma_basic() {
let prices = vec![1.0, 2.0, 3.0];
let result = wma(&prices, 3);
assert!(result[0].is_nan());
assert!(result[1].is_nan());
// weights: 1, 2, 3; denom 6 => (1*1 + 2*2 + 3*3)/6 = 14/6
assert!((result[2] - 14.0 / 6.0).abs() < 1e-10);
}
#[test]
fn bbands_basic() {
let prices = vec![2.0, 2.0, 2.0, 2.0, 2.0];
let (upper, middle, lower) = bbands(&prices, 3, 2.0, 2.0);
assert!((middle[2] - 2.0).abs() < 1e-10);
assert!((upper[2] - 2.0).abs() < 1e-10); // std = 0
assert!((lower[2] - 2.0).abs() < 1e-10);
}
#[test]
fn bbands_varying_prices() {
// Verify against hand-computed values for a small window.
let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let (upper, middle, lower) = bbands(&prices, 3, 2.0, 2.0);
// First two values should be NaN (warmup).
assert!(middle[0].is_nan());
assert!(middle[1].is_nan());
// Window [1,2,3]: mean = 2.0, pop_var = 2/3, std = sqrt(2/3)
let expected_mean = 2.0;
let expected_std = (2.0_f64 / 3.0).sqrt();
assert!((middle[2] - expected_mean).abs() < 1e-10);
assert!((upper[2] - (expected_mean + 2.0 * expected_std)).abs() < 1e-10);
assert!((lower[2] - (expected_mean - 2.0 * expected_std)).abs() < 1e-10);
// Window [2,3,4]: mean = 3.0, pop_var = 2/3, std = sqrt(2/3)
assert!((middle[3] - 3.0).abs() < 1e-10);
assert!((upper[3] - (3.0 + 2.0 * expected_std)).abs() < 1e-10);
// Window [3,4,5]: mean = 4.0, pop_var = 2/3, std = sqrt(2/3)
assert!((middle[4] - 4.0).abs() < 1e-10);
assert!((upper[4] - (4.0 + 2.0 * expected_std)).abs() < 1e-10);
}
#[test]
fn bbands_numerical_stability() {
// Large offset with tiny variation — this is where the naïve sum_sq
// formula suffers from catastrophic cancellation.
let base = 1e12;
let prices: Vec<f64> = (0..100).map(|i| base + (i as f64) * 0.01).collect();
let (upper, middle, lower) = bbands(&prices, 20, 2.0, 2.0);
// Check that middle band matches SMA.
for i in 19..100 {
let window = &prices[i - 19..=i];
let expected_mean: f64 = window.iter().sum::<f64>() / 20.0;
// At scale 1e12, f64 absolute precision is ~2.2e-4; use 1e-3 headroom.
assert!(
(middle[i] - expected_mean).abs() < 1e-3,
"mean mismatch at {i}: got {} expected {}",
middle[i],
expected_mean,
);
// Bands should be above/below middle.
assert!(upper[i] >= middle[i]);
assert!(lower[i] <= middle[i]);
}
}
#[test]
fn bbands_edge_cases() {
// timeperiod == 1: every bar should have std = 0, bands == price.
let prices = vec![10.0, 20.0, 30.0];
let (upper, middle, lower) = bbands(&prices, 1, 2.0, 2.0);
for i in 0..3 {
assert!((middle[i] - prices[i]).abs() < 1e-10);
assert!((upper[i] - prices[i]).abs() < 1e-10);
assert!((lower[i] - prices[i]).abs() < 1e-10);
}
// Input shorter than timeperiod: all NaN.
let (u, m, l) = bbands(&[1.0, 2.0], 5, 2.0, 2.0);
assert!(u.iter().all(|v| v.is_nan()));
assert!(m.iter().all(|v| v.is_nan()));
assert!(l.iter().all(|v| v.is_nan()));
}
#[test]
fn macd_basic() {
// 40 bars of linearly increasing prices — MACD line should converge
let prices: Vec<f64> = (1..=40).map(|i| i as f64).collect();
let (macd_line, signal_line, histogram) = macd(&prices, 3, 5, 2);
// TA-Lib pads MACD line with NaN up to sig_start = slowperiod-1 + signalperiod-1 = 5
for i in 0..5 {
assert!(macd_line[i].is_nan(), "expected NaN at {i}");
}
// First valid macd bar is at index 5 (sig_start)
assert!(!macd_line[5].is_nan());
// First valid signal bar is at index 5
assert!(!signal_line[5].is_nan());
// histogram = macd - signal
assert!((histogram[5] - (macd_line[5] - signal_line[5])).abs() < 1e-10);
}
#[test]
fn macd_invalid_params() {
let prices = vec![1.0; 50];
// fastperiod >= slowperiod should return all-NaN
let (m, s, h) = macd(&prices, 5, 3, 9);
assert!(m.iter().all(|v| v.is_nan()));
assert!(s.iter().all(|v| v.is_nan()));
assert!(h.iter().all(|v| v.is_nan()));
}
}