Merge pull request #7 from pratikbhadane24/wasm-core

Wasm core
This commit is contained in:
Pratik Bhadane
2026-04-01 23:08:40 +05:30
committed by GitHub
20 changed files with 9344 additions and 6234 deletions
+18
View File
@@ -9,6 +9,24 @@ and the project uses [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.1.1] — 2026-04-01
### Added
- Full feature parity across Rust core, Python, and WASM targets.
- 56 new pure-Rust indicator functions in ferro_ta_core: ROC/ROCP/ROCR/ROCR100,
WILLR, AROON/AROONOSC, CCI, BOP, STOCHRSI, APO, PPO, CMO, TRIX, ULTOSC,
DEMA, TEMA, TRIMA, KAMA, T3, SAR, SAREXT, MAMA, MIDPOINT, MIDPRICE,
MACDFIX, MACDEXT, MA (generic dispatcher), MAVP, VAR, LINEARREG variants,
TSF, BETA, CORREL, NATR, and 19 math operators/transforms.
- 120+ new WASM bindings: all 61 candlestick patterns (via macro), 9 streaming
API structs, options pricing/greeks/IV/chain/surface, futures basis/roll/curve/
synthetic, backtest engine (close-only + OHLCV), walk-forward analysis,
Monte Carlo bootstrap, performance metrics, batch operations, portfolio
analytics, and signal utilities.
- `workflow_dispatch` trigger added to `wasm-publish.yml` for manual npm
publishing.
## [1.0.6] — 2026-03-24
### Added
Generated
+2 -2
View File
@@ -207,7 +207,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "ferro_ta"
version = "1.1.0"
version = "1.1.1"
dependencies = [
"criterion",
"ferro_ta_core",
@@ -222,7 +222,7 @@ dependencies = [
[[package]]
name = "ferro_ta_core"
version = "1.1.0"
version = "1.1.1"
dependencies = [
"criterion",
"serde",
+2 -2
View File
@@ -5,7 +5,7 @@ resolver = "2"
[package]
name = "ferro_ta"
version = "1.1.0"
version = "1.1.1"
edition = "2021"
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
license = "MIT"
@@ -30,7 +30,7 @@ ndarray = "0.16"
rayon = "1.10"
log = "0.4"
pyo3-log = "0.12"
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.0", features = ["serde"] }
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.1", features = ["serde"] }
[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }
+1 -1
View File
@@ -1,5 +1,5 @@
{% set name = "ferro-ta" %}
{% set version = "1.1.0" %}
{% set version = "1.1.1" %}
package:
name: {{ name|lower }}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ferro_ta_core"
version = "1.1.0"
version = "1.1.1"
edition = "2021"
description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
license = "MIT"
+1 -1
View File
@@ -13,7 +13,7 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for:
```toml
[dependencies]
ferro_ta_core = "1.1.0"
ferro_ta_core = "1.1.1"
```
## Design
+55
View File
@@ -113,6 +113,61 @@ pub fn sliding_min(real: &[f64], timeperiod: usize) -> Vec<f64> {
result
}
// ---------------------------------------------------------------------------
// Element-wise arithmetic operators
// ---------------------------------------------------------------------------
/// Element-wise addition of two arrays.
pub fn add(a: &[f64], b: &[f64]) -> Vec<f64> {
a.iter().zip(b.iter()).map(|(&x, &y)| x + y).collect()
}
/// Element-wise subtraction of two arrays.
pub fn sub(a: &[f64], b: &[f64]) -> Vec<f64> {
a.iter().zip(b.iter()).map(|(&x, &y)| x - y).collect()
}
/// Element-wise multiplication of two arrays.
pub fn mult(a: &[f64], b: &[f64]) -> Vec<f64> {
a.iter().zip(b.iter()).map(|(&x, &y)| x * y).collect()
}
/// Element-wise division of two arrays (NaN where b=0).
pub fn div(a: &[f64], b: &[f64]) -> Vec<f64> {
a.iter()
.zip(b.iter())
.map(|(&x, &y)| if y != 0.0 { x / y } else { f64::NAN })
.collect()
}
// ---------------------------------------------------------------------------
// Element-wise math transforms
// ---------------------------------------------------------------------------
macro_rules! unary_transform {
($name:ident, $method:ident) => {
pub fn $name(real: &[f64]) -> Vec<f64> {
real.iter().map(|&x| x.$method()).collect()
}
};
}
unary_transform!(math_acos, acos);
unary_transform!(math_asin, asin);
unary_transform!(math_atan, atan);
unary_transform!(math_ceil, ceil);
unary_transform!(math_cos, cos);
unary_transform!(math_cosh, cosh);
unary_transform!(math_exp, exp);
unary_transform!(math_floor, floor);
unary_transform!(math_ln, ln);
unary_transform!(math_log10, log10);
unary_transform!(math_sin, sin);
unary_transform!(math_sinh, sinh);
unary_transform!(math_sqrt, sqrt);
unary_transform!(math_tan, tan);
unary_transform!(math_tanh, tanh);
#[cfg(test)]
mod tests {
use super::*;
+429
View File
@@ -446,6 +446,435 @@ pub fn adxr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<
result
}
// ---------------------------------------------------------------------------
// Rate of Change variants
// ---------------------------------------------------------------------------
/// Rate of Change: `(close[i] - close[i-p]) / close[i-p] * 100`.
pub fn roc(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
for i in timeperiod..n {
let prev = close[i - timeperiod];
if prev != 0.0 {
result[i] = (close[i] - prev) / prev * 100.0;
}
}
result
}
/// Rate of Change Percentage: `(close[i] - close[i-p]) / close[i-p]`.
pub fn rocp(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
for i in timeperiod..n {
let prev = close[i - timeperiod];
if prev != 0.0 {
result[i] = (close[i] - prev) / prev;
}
}
result
}
/// Rate of Change Ratio: `close[i] / close[i-p]`.
pub fn rocr(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
for i in timeperiod..n {
let prev = close[i - timeperiod];
if prev != 0.0 {
result[i] = close[i] / prev;
}
}
result
}
/// Rate of Change Ratio x 100: `close[i] / close[i-p] * 100`.
pub fn rocr100(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
for i in timeperiod..n {
let prev = close[i - timeperiod];
if prev != 0.0 {
result[i] = close[i] / prev * 100.0;
}
}
result
}
// ---------------------------------------------------------------------------
// Williams %R
// ---------------------------------------------------------------------------
/// Williams %R: `-100 * (HH - close) / (HH - LL)` over the window.
/// Returns values in `[-100, 0]`.
pub fn willr(high: &[f64], low: &[f64], close: &[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 mut highest = f64::NEG_INFINITY;
let mut lowest = f64::INFINITY;
for j in start..=i {
if high[j] > highest {
highest = high[j];
}
if low[j] < lowest {
lowest = low[j];
}
}
let range = highest - lowest;
result[i] = if range != 0.0 {
-100.0 * (highest - close[i]) / range
} else {
-50.0
};
}
result
}
// ---------------------------------------------------------------------------
// Aroon
// ---------------------------------------------------------------------------
/// Aroon indicator. Returns `(aroon_down, aroon_up)`.
pub fn aroon(high: &[f64], low: &[f64], timeperiod: usize) -> (Vec<f64>, Vec<f64>) {
let n = high.len();
let mut aroon_down = vec![f64::NAN; n];
let mut aroon_up = vec![f64::NAN; n];
if timeperiod == 0 || n <= timeperiod {
return (aroon_down, aroon_up);
}
let period_f = timeperiod as f64;
let window_size = timeperiod + 1;
for i in timeperiod..n {
let start = i + 1 - window_size;
let mut max_val = high[start];
let mut min_val = low[start];
let mut max_idx = 0usize;
let mut min_idx = 0usize;
for j in 0..window_size {
if high[start + j] >= max_val {
max_val = high[start + j];
max_idx = j;
}
if low[start + j] <= min_val {
min_val = low[start + j];
min_idx = j;
}
}
aroon_up[i] = 100.0 * (max_idx as f64) / period_f;
aroon_down[i] = 100.0 * (min_idx as f64) / period_f;
}
(aroon_down, aroon_up)
}
/// Aroon Oscillator: `aroon_up - aroon_down`.
pub fn aroonosc(high: &[f64], low: &[f64], timeperiod: usize) -> Vec<f64> {
let (down, up) = aroon(high, low, timeperiod);
up.iter()
.zip(down.iter())
.map(|(&u, &d)| {
if u.is_nan() || d.is_nan() {
f64::NAN
} else {
u - d
}
})
.collect()
}
// ---------------------------------------------------------------------------
// CCI
// ---------------------------------------------------------------------------
/// Commodity Channel Index: `(tp - SMA(tp)) / (0.015 * MAD)`.
pub fn cci(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = high.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let tp: Vec<f64> = high
.iter()
.zip(low.iter())
.zip(close.iter())
.map(|((&h, &l), &c)| (h + l + c) / 3.0)
.collect();
for i in (timeperiod - 1)..n {
let window = &tp[(i + 1 - timeperiod)..=i];
let mean: f64 = window.iter().sum::<f64>() / timeperiod as f64;
let mad: f64 = window.iter().map(|&x| (x - mean).abs()).sum::<f64>() / timeperiod as f64;
result[i] = if mad != 0.0 {
(tp[i] - mean) / (0.015 * mad)
} else {
0.0
};
}
result
}
// ---------------------------------------------------------------------------
// BOP
// ---------------------------------------------------------------------------
/// Balance of Power: `(close - open) / (high - low)`.
pub fn bop(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
open.iter()
.zip(high.iter())
.zip(low.iter())
.zip(close.iter())
.map(|(((&o, &h), &l), &c)| {
let range = h - l;
if range != 0.0 {
(c - o) / range
} else {
0.0
}
})
.collect()
}
// ---------------------------------------------------------------------------
// Stochastic RSI
// ---------------------------------------------------------------------------
/// Stochastic RSI. Returns `(fastk, fastd)`.
pub fn stochrsi(
close: &[f64],
timeperiod: usize,
fastk_period: usize,
fastd_period: usize,
) -> (Vec<f64>, Vec<f64>) {
let n = close.len();
let nan_pair = || (vec![f64::NAN; n], vec![f64::NAN; n]);
if timeperiod == 0 || fastk_period == 0 || fastd_period == 0 {
return nan_pair();
}
let rsi_vals = rsi(close, timeperiod);
let rsi_warmup = timeperiod;
let k_warmup = rsi_warmup + fastk_period - 1;
let d_warmup = k_warmup + fastd_period - 1;
let mut fastk = vec![f64::NAN; n];
let mut fastd = vec![f64::NAN; n];
for i in k_warmup..n {
if rsi_vals[i].is_nan() {
continue;
}
let start = i + 1 - fastk_period;
if (start..=i).any(|j| rsi_vals[j].is_nan()) {
continue;
}
let mx = rsi_vals[start..=i]
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
let mn = rsi_vals[start..=i]
.iter()
.cloned()
.fold(f64::INFINITY, f64::min);
fastk[i] = if mx != mn {
100.0 * (rsi_vals[i] - mn) / (mx - mn)
} else {
50.0
};
}
for i in d_warmup..n {
let start = i + 1 - fastd_period;
let window = &fastk[start..=i];
if window.iter().all(|v| !v.is_nan()) {
fastd[i] = window.iter().sum::<f64>() / fastd_period as f64;
}
}
(fastk, fastd)
}
// ---------------------------------------------------------------------------
// APO / PPO
// ---------------------------------------------------------------------------
/// Absolute Price Oscillator: `fast EMA - slow EMA`.
pub fn apo(close: &[f64], fastperiod: usize, slowperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if fastperiod == 0 || slowperiod == 0 || fastperiod >= slowperiod {
return result;
}
let fast = crate::overlap::ema(close, fastperiod);
let slow = crate::overlap::ema(close, slowperiod);
let warmup = slowperiod - 1;
for i in warmup..n {
if !fast[i].is_nan() && !slow[i].is_nan() {
result[i] = fast[i] - slow[i];
}
}
result
}
/// Percentage Price Oscillator: `(fast EMA - slow EMA) / slow EMA * 100`.
/// Returns `(ppo_line, signal_line, histogram)`.
pub fn ppo(
close: &[f64],
fastperiod: usize,
slowperiod: usize,
signalperiod: usize,
) -> (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 = crate::overlap::ema(close, fastperiod);
let slow = crate::overlap::ema(close, slowperiod);
let warmup = slowperiod - 1;
let mut ppo_line = vec![f64::NAN; n];
for i in warmup..n {
if !fast[i].is_nan() && !slow[i].is_nan() && slow[i] != 0.0 {
ppo_line[i] = (fast[i] - slow[i]) / slow[i] * 100.0;
}
}
// Signal line = EMA of PPO line (only over valid values)
let signal = crate::overlap::ema(&ppo_line, signalperiod);
let mut signal_line = vec![f64::NAN; n];
let mut hist = vec![f64::NAN; n];
let sig_warmup = warmup + signalperiod - 1;
for i in sig_warmup..n {
if !ppo_line[i].is_nan() && !signal[i].is_nan() {
signal_line[i] = signal[i];
hist[i] = ppo_line[i] - signal[i];
}
}
(ppo_line, signal_line, hist)
}
// ---------------------------------------------------------------------------
// CMO
// ---------------------------------------------------------------------------
/// Chande Momentum Oscillator: `100 * (sum_gains - sum_losses) / (sum_gains + sum_losses)`.
pub fn cmo(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod + 1 {
return result;
}
let changes: Vec<f64> = close.windows(2).map(|w| w[1] - w[0]).collect();
for i in timeperiod..n {
let mut ups = 0.0_f64;
let mut downs = 0.0_f64;
for ch in &changes[(i - timeperiod)..i] {
if *ch > 0.0 {
ups += ch;
} else {
downs -= ch;
}
}
let denom = ups + downs;
result[i] = if denom != 0.0 {
100.0 * (ups - downs) / denom
} else {
0.0
};
}
result
}
// ---------------------------------------------------------------------------
// TRIX
// ---------------------------------------------------------------------------
/// TRIX: 1-period rate of change of triple-smoothed EMA.
pub fn trix(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);
// Triple EMA: EMA(EMA(EMA(close)))
let ema1 = crate::overlap::ema(close, timeperiod);
let ema2 = crate::overlap::ema(&ema1, timeperiod);
let ema3 = crate::overlap::ema(&ema2, timeperiod);
for i in (warmup + 1)..n {
let prev = ema3[i - 1];
if !ema3[i].is_nan() && !prev.is_nan() && prev != 0.0 {
result[i] = (ema3[i] - prev) / prev * 100.0;
}
}
result
}
// ---------------------------------------------------------------------------
// Ultimate Oscillator
// ---------------------------------------------------------------------------
/// Ultimate Oscillator: weighted average of buying pressure over three periods.
pub fn ultosc(
high: &[f64],
low: &[f64],
close: &[f64],
timeperiod1: usize,
timeperiod2: usize,
timeperiod3: usize,
) -> Vec<f64> {
let n = high.len();
let mut result = vec![f64::NAN; n];
if timeperiod1 == 0 || timeperiod2 == 0 || timeperiod3 == 0 || n < 2 {
return result;
}
let max_period = timeperiod1.max(timeperiod2).max(timeperiod3);
if n <= max_period {
return result;
}
let mut bp = vec![0.0_f64; n];
let mut tr = vec![0.0_f64; n];
for i in 1..n {
let true_low = low[i].min(close[i - 1]);
let true_high = high[i].max(close[i - 1]);
bp[i] = close[i] - true_low;
tr[i] = true_high - true_low;
}
for i in max_period..n {
let avg = |period: usize| -> f64 {
let sum_bp: f64 = bp[(i + 1 - period)..=i].iter().sum();
let sum_tr: f64 = tr[(i + 1 - period)..=i].iter().sum();
if sum_tr != 0.0 {
sum_bp / sum_tr
} else {
0.0
}
};
result[i] =
100.0 * (4.0 * avg(timeperiod1) + 2.0 * avg(timeperiod2) + avg(timeperiod3)) / 7.0;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
+520
View File
@@ -447,6 +447,526 @@ pub fn macd(
(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::*;
+223
View File
@@ -24,6 +24,229 @@ pub fn stddev(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec<f64> {
result
}
/// Rolling population variance, scaled by `nbdev²`.
pub fn var(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec<f64> {
let n = real.len();
let mut result = vec![f64::NAN; n];
if timeperiod < 1 || n < timeperiod {
return result;
}
for i in (timeperiod - 1)..n {
let window = &real[i + 1 - timeperiod..=i];
let mean: f64 = window.iter().sum::<f64>() / timeperiod as f64;
let variance: f64 =
window.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / timeperiod as f64;
result[i] = variance * nbdev * nbdev;
}
result
}
// ---------------------------------------------------------------------------
// Linear regression helpers
// ---------------------------------------------------------------------------
fn rolling_linreg_apply<F>(prices: &[f64], timeperiod: usize, mut map: F) -> Vec<f64>
where
F: FnMut(f64, f64) -> f64,
{
let n = prices.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let period = timeperiod as f64;
let last_x = (timeperiod - 1) as f64;
let sum_x = last_x * period / 2.0;
let sum_x2 = last_x * period * (2.0 * period - 1.0) / 6.0;
let denom = period * sum_x2 - sum_x * sum_x;
let mut sum_y: f64 = prices[..timeperiod].iter().sum();
let mut sum_xy: f64 = prices[..timeperiod]
.iter()
.enumerate()
.map(|(idx, &v)| idx as f64 * v)
.sum();
for end in (timeperiod - 1)..n {
let slope = if denom != 0.0 {
(period * sum_xy - sum_x * sum_y) / denom
} else {
0.0
};
let intercept = (sum_y - slope * sum_x) / period;
result[end] = map(slope, intercept);
if end + 1 < n {
let outgoing = prices[end + 1 - timeperiod];
let incoming = prices[end + 1];
let prev_sum_y = sum_y;
sum_y = prev_sum_y - outgoing + incoming;
sum_xy = sum_xy - (prev_sum_y - outgoing) + last_x * incoming;
}
}
result
}
/// Linear regression fitted value at the last point of the window.
pub fn linearreg(close: &[f64], timeperiod: usize) -> Vec<f64> {
let last_x = if timeperiod > 0 {
(timeperiod - 1) as f64
} else {
0.0
};
rolling_linreg_apply(close, timeperiod, |slope, intercept| {
intercept + slope * last_x
})
}
/// Slope of the rolling linear regression line.
pub fn linearreg_slope(close: &[f64], timeperiod: usize) -> Vec<f64> {
rolling_linreg_apply(close, timeperiod, |slope, _| slope)
}
/// Intercept of the rolling linear regression line.
pub fn linearreg_intercept(close: &[f64], timeperiod: usize) -> Vec<f64> {
rolling_linreg_apply(close, timeperiod, |_, intercept| intercept)
}
/// Angle of the regression line in degrees.
pub fn linearreg_angle(close: &[f64], timeperiod: usize) -> Vec<f64> {
rolling_linreg_apply(close, timeperiod, |slope, _| {
slope.atan() * 180.0 / std::f64::consts::PI
})
}
/// Time Series Forecast: linear regression extrapolated one period ahead.
pub fn tsf(close: &[f64], timeperiod: usize) -> Vec<f64> {
let forecast_x = timeperiod as f64;
rolling_linreg_apply(close, timeperiod, |slope, intercept| {
intercept + slope * forecast_x
})
}
// ---------------------------------------------------------------------------
// Beta (rolling, return-based)
// ---------------------------------------------------------------------------
/// Rolling beta: regression of real1 daily returns on real0 daily returns.
pub fn beta(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec<f64> {
let n = real0.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n <= timeperiod {
return result;
}
let price_return = |curr: f64, prev: f64| -> f64 {
if prev != 0.0 {
curr / prev - 1.0
} else {
f64::NAN
}
};
let rx: Vec<f64> = real0.windows(2).map(|w| price_return(w[1], w[0])).collect();
let ry: Vec<f64> = real1.windows(2).map(|w| price_return(w[1], w[0])).collect();
let period = timeperiod as f64;
let mut sum_rx = 0.0_f64;
let mut sum_ry = 0.0_f64;
let mut sum_rx2 = 0.0_f64;
let mut sum_rxry = 0.0_f64;
let mut invalid = 0usize;
for idx in 0..timeperiod {
let (ret_x, ret_y) = (rx[idx], ry[idx]);
if ret_x.is_finite() && ret_y.is_finite() {
sum_rx += ret_x;
sum_ry += ret_y;
sum_rx2 += ret_x * ret_x;
sum_rxry += ret_x * ret_y;
} else {
invalid += 1;
}
}
for end in timeperiod..n {
result[end] = if invalid == 0 {
let denom = period * sum_rx2 - sum_rx * sum_rx;
if denom != 0.0 {
(period * sum_rxry - sum_rx * sum_ry) / denom
} else {
f64::NAN
}
} else {
f64::NAN
};
if end + 1 < n {
let out = end - timeperiod;
let (ox, oy) = (rx[out], ry[out]);
if ox.is_finite() && oy.is_finite() {
sum_rx -= ox;
sum_ry -= oy;
sum_rx2 -= ox * ox;
sum_rxry -= ox * oy;
} else {
invalid -= 1;
}
let (ix, iy) = (rx[end], ry[end]);
if ix.is_finite() && iy.is_finite() {
sum_rx += ix;
sum_ry += iy;
sum_rx2 += ix * ix;
sum_rxry += ix * iy;
} else {
invalid += 1;
}
}
}
result
}
// ---------------------------------------------------------------------------
// Correlation (rolling Pearson)
// ---------------------------------------------------------------------------
/// Rolling Pearson correlation coefficient between two series.
pub fn correl(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec<f64> {
let n = real0.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let period = timeperiod as f64;
let mut sum_x: f64 = real0[..timeperiod].iter().sum();
let mut sum_y: f64 = real1[..timeperiod].iter().sum();
let mut sum_x2: f64 = real0[..timeperiod].iter().map(|v| v * v).sum();
let mut sum_y2: f64 = real1[..timeperiod].iter().map(|v| v * v).sum();
let mut sum_xy: f64 = real0[..timeperiod]
.iter()
.zip(real1[..timeperiod].iter())
.map(|(&a, &b)| a * b)
.sum();
#[allow(clippy::needless_range_loop)]
for end in (timeperiod - 1)..n {
let denom_x = period * sum_x2 - sum_x * sum_x;
let denom_y = period * sum_y2 - sum_y * sum_y;
result[end] = if denom_x > 0.0 && denom_y > 0.0 {
(period * sum_xy - sum_x * sum_y) / (denom_x * denom_y).sqrt()
} else {
f64::NAN
};
if end + 1 < n {
let out = end + 1 - timeperiod;
let inc = end + 1;
sum_x += real0[inc] - real0[out];
sum_y += real1[inc] - real1[out];
sum_x2 += real0[inc] * real0[inc] - real0[out] * real0[out];
sum_y2 += real1[inc] * real1[inc] - real1[out] * real1[out];
sum_xy += real0[inc] * real1[inc] - real0[out] * real1[out];
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
+16
View File
@@ -62,6 +62,22 @@ pub fn trange(high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
result
}
/// Normalized Average True Range: `ATR / close * 100`.
pub fn natr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
let atr_vals = atr(high, low, close, timeperiod);
atr_vals
.iter()
.zip(close.iter())
.map(|(&a, &c)| {
if a.is_nan() || c == 0.0 {
f64::NAN
} else {
a / c * 100.0
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+6601 -6219
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
Release Notes
=============
These docs track package version ``1.1.0``.
These docs track package version ``1.1.1``.
1.1.0-audit (2026-03-28)
------------------------
+1 -1
View File
@@ -180,7 +180,7 @@ For source builds, packaging details, and platform notes, see
Release status
--------------
These docs track package version ``1.1.0``.
These docs track package version ``1.1.1``.
- Release notes by version: :doc:`changelog`
- Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "ferro-ta"
version = "1.1.0"
version = "1.1.1"
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
readme = "README.md"
license = { text = "MIT" }
Generated
+1 -1
View File
@@ -950,7 +950,7 @@ wheels = [
[[package]]
name = "ferro-ta"
version = "1.1.0"
version = "1.1.1"
source = { editable = "." }
dependencies = [
{ name = "numpy" },
+2 -2
View File
@@ -49,11 +49,11 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "ferro_ta_core"
version = "1.1.0"
version = "1.1.1"
[[package]]
name = "ferro_ta_wasm"
version = "1.1.0"
version = "1.1.1"
dependencies = [
"ferro_ta_core",
"js-sys",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ferro_ta_wasm"
version = "1.1.0"
version = "1.1.1"
edition = "2021"
description = "WebAssembly bindings for ferro-ta technical analysis indicators"
license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ferro-ta-wasm",
"version": "1.1.0",
"version": "1.1.1",
"description": "WebAssembly bindings for ferro-ta technical analysis indicators",
"main": "pkg/ferro_ta_wasm.js",
"types": "pkg/ferro_ta_wasm.d.ts",
+1467
View File
File diff suppressed because it is too large Load Diff