diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b7fb86..899017bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Family 01 — Moving Averages.** `ALMA` (Arnaud Legoux Moving Average): + Gaussian-weighted moving average with configurable centre (`offset` in + `[0, 1]`) and kernel width (`sigma > 0`). Community-standard defaults + `(period = 9, offset = 0.85, sigma = 6.0)` available via `Alma::classic()`. + Exposed in all four bindings (Rust, Python, Node, WASM). + ## [0.2.7] - 2026-05-24 ### Added diff --git a/README.md b/README.md index cbde05d9..d0d7a4fb 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,13 @@ python -m benchmarks.compare_libraries ## Indicators -71 streaming-first indicators across eight families. Every one passes the +72 streaming-first indicators across eight families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. | Family | Indicators | |--------|-----------| -| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA | +| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA | | Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator | | Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter | | Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power | diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index f9c7aff8..fd3ca2d4 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -37,6 +37,7 @@ const scalarFactories = { ROC: () => new wickra.ROC(12), TRIX: () => new wickra.TRIX(9), KAMA: () => new wickra.KAMA(10, 2, 30), + ALMA: () => new wickra.ALMA(9, 0.85, 6.0), SMMA: () => new wickra.SMMA(14), TRIMA: () => new wickra.TRIMA(20), ZLEMA: () => new wickra.ZLEMA(14), @@ -258,3 +259,16 @@ test('LinRegAngle of a unit-slope series is 45 degrees', () => { const out = new wickra.LinRegAngle(5).batch([1, 2, 3, 4, 5, 6]); assert.ok(Math.abs(out[4] - 45) < 1e-9); }); + +test('ALMA(3, 0.85, 6) reference value on [10, 20, 30]', () => { + // m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5. + const out = new wickra.ALMA(3, 0.85, 6).batch([10, 20, 30]); + assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1])); + const w = [0, 1, 2].map((i) => Math.exp(-Math.pow(i - 1.7, 2) / 0.5)); + const s = w[0] + w[1] + w[2]; + const expected = (10 * w[0] + 20 * w[1] + 30 * w[2]) / s; + assert.ok(Math.abs(out[2] - expected) < 1e-12); + // The heavy offset toward the newest sample lifts the average above the + // simple mean of 20. + assert.ok(out[2] > 20); +}); diff --git a/bindings/node/index.js b/bindings/node/index.js index 7e75b0f6..b5c9e9d9 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, ALMA, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -349,6 +349,7 @@ module.exports.RollingVWAP = RollingVWAP module.exports.AwesomeOscillator = AwesomeOscillator module.exports.Aroon = Aroon module.exports.KAMA = KAMA +module.exports.ALMA = ALMA module.exports.T3 = T3 module.exports.TSI = TSI module.exports.PMO = PMO diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 3ef53a24..d3802ac5 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -1103,6 +1103,42 @@ impl KamaNode { } } +// ============================== ALMA ============================== + +#[napi(js_name = "ALMA")] +pub struct AlmaNode { + inner: wc::Alma, +} +#[napi] +impl AlmaNode { + #[napi(constructor)] + pub fn new(period: u32, offset: f64, sigma: f64) -> napi::Result { + Ok(Self { + inner: wc::Alma::new(clamp_period(period), offset, sigma).map_err(map_err)?, + }) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } +} + // ============================== T3 ============================== #[napi(js_name = "T3")] diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 6a4bd208..5b7d5f2c 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -38,6 +38,7 @@ from ._wickra import ( ZLEMA, T3, VWMA, + ALMA, # Momentum RSI, MACD, @@ -119,6 +120,7 @@ __all__ = [ "ZLEMA", "T3", "VWMA", + "ALMA", # Momentum "RSI", "MACD", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 41b15d4e..b821871e 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -812,6 +812,67 @@ impl PyKama { } } +// ============================== ALMA ============================== + +#[pyclass(name = "ALMA", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyAlma { + inner: wc::Alma, +} + +#[pymethods] +impl PyAlma { + #[new] + #[pyo3(signature = (period=9, offset=0.85, sigma=6.0))] + fn new(period: usize, offset: f64, sigma: f64) -> PyResult { + Ok(Self { + inner: wc::Alma::new(period, offset, sigma).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn offset(&self) -> f64 { + self.inner.offset() + } + #[getter] + fn sigma(&self) -> f64 { + self.inner.sigma() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!( + "ALMA(period={}, offset={}, sigma={})", + self.inner.period(), + self.inner.offset(), + self.inner.sigma() + ) + } +} + // ============================== CCI ============================== #[pyclass(name = "CCI", module = "wickra._wickra", skip_from_py_object)] @@ -4494,6 +4555,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index c481b26c..5e5bfe19 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -66,6 +66,28 @@ def test_rsi_wilder_textbook_first_value(): assert math.isclose(out[14], 70.464, abs_tol=0.05) +def test_alma_constant_series_yields_the_constant(): + # ALMA's Gaussian weights are normalised, so any constant series is + # reproduced exactly after warmup. + out = ta.ALMA(9, 0.85, 6.0).batch(np.full(30, 42.0, dtype=np.float64)) + assert np.all(np.isnan(out[:8])) + np.testing.assert_allclose(out[8:], 42.0, atol=1e-12) + + +def test_alma_reference_value_period_3(): + # ALMA(period=3, offset=0.85, sigma=6) on [10, 20, 30]. + # m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5. + out = ta.ALMA(3, 0.85, 6.0).batch(np.array([10.0, 20.0, 30.0])) + assert math.isnan(out[0]) and math.isnan(out[1]) + # Independently compute the expected Gaussian-weighted sum. + w = np.exp(-((np.arange(3, dtype=np.float64) - 1.7) ** 2) / 0.5) + expected = float(np.dot([10.0, 20.0, 30.0], w) / w.sum()) + assert math.isclose(out[2], expected, abs_tol=1e-12) + # Sanity: heavy offset toward the newest sample lifts the average above + # the simple mean of 20. + assert out[2] > 20.0 + + def test_macd_constant_series_converges_to_zero(): out = ta.MACD().batch(np.full(200, 100.0)) # Last row's MACD and signal must be ~0. diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index e4919bd4..747ec64c 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -44,6 +44,7 @@ SCALAR = [ (ta.SMMA, (14,)), (ta.TRIMA, (20,)), (ta.ZLEMA, (14,)), + (ta.ALMA, (9, 0.85, 6.0)), (ta.T3, (5, 0.7)), (ta.MOM, (10,)), (ta.CMO, (14,)), diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 56b98a81..88965eae 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -79,6 +79,7 @@ wasm_scalar_indicator!(WasmSmma, "SMMA", wc::Smma, period: usize); wasm_scalar_indicator!(WasmTrima, "TRIMA", wc::Trima, period: usize); wasm_scalar_indicator!(WasmZlema, "ZLEMA", wc::Zlema, period: usize); wasm_scalar_indicator!(WasmT3, "T3", wc::T3, period: usize, v: f64); +wasm_scalar_indicator!(WasmAlma, "ALMA", wc::Alma, period: usize, offset: f64, sigma: f64); wasm_scalar_indicator!(WasmMom, "MOM", wc::Mom, period: usize); wasm_scalar_indicator!(WasmCmo, "CMO", wc::Cmo, period: usize); wasm_scalar_indicator!(WasmTsi, "TSI", wc::Tsi, long: usize, short: usize); diff --git a/crates/wickra-core/src/indicators/alma.rs b/crates/wickra-core/src/indicators/alma.rs new file mode 100644 index 00000000..3ef911a2 --- /dev/null +++ b/crates/wickra-core/src/indicators/alma.rs @@ -0,0 +1,335 @@ +//! Arnaud Legoux Moving Average (ALMA). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Arnaud Legoux Moving Average — a Gaussian-weighted moving average. +/// +/// Each output is a weighted sum of the last `period` inputs: +/// +/// ```text +/// w[i] = exp(-(i - m)^2 / (2 * s^2)) for i in 0..period +/// m = offset * (period - 1) +/// s = period / sigma +/// ALMA = sum(price[i] * w[i]) / sum(w[i]) +/// ``` +/// +/// The Gaussian is centred on the relative index `offset * (period - 1)`, so +/// `offset = 0.85` puts the peak near the newest sample (responsive), while +/// `offset = 0.5` centres the peak in the middle of the window (smooth). +/// `sigma` controls how concentrated the Gaussian is: larger `sigma` -> +/// narrower kernel, smaller `sigma` -> broader (closer to SMA). +/// +/// Reference: Arnaud Legoux and Dimitrios Kouzis-Loukas, 2009. +/// +/// # Defaults +/// +/// The community-standard parameters are `period = 9`, `offset = 0.85`, +/// `sigma = 6.0`. The first output lands after exactly `period` inputs. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Alma, Indicator}; +/// +/// let mut alma = Alma::new(9, 0.85, 6.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = alma.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Alma { + period: usize, + offset: f64, + sigma: f64, + /// Pre-computed, normalised weights (sum to 1). `weights[0]` is the oldest + /// sample in the window, `weights[period - 1]` the newest. + weights: Vec, + window: VecDeque, + current: Option, +} + +impl Alma { + /// Construct a new ALMA with the given period, offset and sigma. + /// + /// # Errors + /// + /// - [`Error::PeriodZero`] if `period == 0`. + /// - [`Error::InvalidPeriod`] if `offset` is outside `[0.0, 1.0]` or + /// `sigma <= 0.0` or either of `offset` / `sigma` is non-finite. + pub fn new(period: usize, offset: f64, sigma: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !offset.is_finite() || !(0.0..=1.0).contains(&offset) { + return Err(Error::InvalidPeriod { + message: "ALMA offset must be a finite value in [0, 1]", + }); + } + if !sigma.is_finite() || sigma <= 0.0 { + return Err(Error::InvalidPeriod { + message: "ALMA sigma must be a finite positive value", + }); + } + let m = offset * (period as f64 - 1.0); + let s = period as f64 / sigma; + let denom = 2.0 * s * s; + // The raw Gaussian weights sum to a strictly positive value because + // every term is `exp(_) > 0`, so the normalisation below cannot divide + // by zero. + let mut raw: Vec = (0..period) + .map(|i| (-((i as f64 - m).powi(2)) / denom).exp()) + .collect(); + let sum: f64 = raw.iter().sum(); + for w in &mut raw { + *w /= sum; + } + Ok(Self { + period, + offset, + sigma, + weights: raw, + window: VecDeque::with_capacity(period), + current: None, + }) + } + + /// Construct ALMA with the community-standard parameters + /// `(period = 9, offset = 0.85, sigma = 6.0)`. + pub fn classic() -> Self { + Self::new(9, 0.85, 6.0).expect("classic ALMA parameters are valid") + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured offset. + pub const fn offset(&self) -> f64 { + self.offset + } + + /// Configured sigma. + pub const fn sigma(&self) -> f64 { + self.sigma + } +} + +impl Indicator for Alma { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.current; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let mut acc = 0.0; + for (w, p) in self.weights.iter().zip(self.window.iter()) { + acc += w * p; + } + self.current = Some(acc); + Some(acc) + } + + fn reset(&mut self) { + self.window.clear(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "ALMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Alma::new(0, 0.85, 6.0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_invalid_offset() { + assert!(matches!( + Alma::new(9, -0.1, 6.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Alma::new(9, 1.1, 6.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Alma::new(9, f64::NAN, 6.0), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn rejects_invalid_sigma() { + assert!(matches!( + Alma::new(9, 0.85, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Alma::new(9, 0.85, -1.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Alma::new(9, 0.85, f64::INFINITY), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let alma = Alma::new(9, 0.85, 6.0).unwrap(); + assert_eq!(alma.period(), 9); + assert_eq!(alma.warmup_period(), 9); + assert_eq!(alma.name(), "ALMA"); + assert!((alma.offset() - 0.85).abs() < 1e-12); + assert!((alma.sigma() - 6.0).abs() < 1e-12); + // Weights are normalised by construction. + let sum: f64 = alma.weights.iter().sum(); + assert_relative_eq!(sum, 1.0, epsilon = 1e-12); + } + + #[test] + fn classic_factory() { + let a = Alma::classic(); + assert_eq!(a.period(), 9); + assert!((a.offset() - 0.85).abs() < 1e-12); + assert!((a.sigma() - 6.0).abs() < 1e-12); + } + + #[test] + fn constant_series_yields_the_constant() { + // Normalised weights sum to 1, so any constant is reproduced exactly. + let mut alma = Alma::new(9, 0.85, 6.0).unwrap(); + let out = alma.batch(&[42.0_f64; 40]); + for v in out.iter().skip(8).flatten() { + assert_relative_eq!(*v, 42.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_first_value_at_period() { + let mut alma = Alma::new(5, 0.85, 6.0).unwrap(); + for i in 0..4 { + assert_eq!(alma.update(f64::from(i)), None); + } + assert!(alma.update(4.0).is_some()); + } + + #[test] + fn reference_value_period_3() { + // ALMA(period=3, offset=0.85, sigma=6) on [10, 20, 30]. + // m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5. + // Independently compute the normalised Gaussian weights and the + // expected weighted sum, then check the indicator output matches. + // Computing the expectation here (rather than pinning a printed + // constant) keeps the test stable across libm `exp` implementations. + let mut alma = Alma::new(3, 0.85, 6.0).unwrap(); + alma.update(10.0); + alma.update(20.0); + let v = alma.update(30.0).expect("ALMA emits after period"); + + let w0 = (-((0.0_f64 - 1.7).powi(2)) / 0.5).exp(); + let w1 = (-((1.0_f64 - 1.7).powi(2)) / 0.5).exp(); + let w2 = (-((2.0_f64 - 1.7).powi(2)) / 0.5).exp(); + let s = w0 + w1 + w2; + let expected = (10.0 * w0 + 20.0 * w1 + 30.0 * w2) / s; + + // The weighted sum is heavily skewed toward the newest sample so the + // output must sit close to but below the latest input (30). + assert!(v > 25.0 && v < 30.0, "ALMA(3) on [10,20,30] = {v}"); + assert_relative_eq!(v, expected, epsilon = 1e-12); + } + + #[test] + fn offset_zero_centres_on_oldest_sample() { + // With offset = 0 the Gaussian peaks at index 0, so ALMA leans toward + // the oldest sample in the window and away from the newest. + let mut alma = Alma::new(5, 0.0, 6.0).unwrap(); + let series: Vec = (1..=5).map(f64::from).collect(); + let mut last = None; + for p in &series { + last = alma.update(*p); + } + let v = last.unwrap(); + let mean = series.iter().sum::() / series.len() as f64; + // Oldest sample is 1.0, mean is 3.0; an offset-0 ALMA should sit + // strictly below the mean. + assert!(v < mean, "{v} should be less than {mean}"); + } + + #[test] + fn offset_one_centres_on_newest_sample() { + // Symmetric to the above: offset = 1 leans toward the newest sample. + let mut alma = Alma::new(5, 1.0, 6.0).unwrap(); + let series: Vec = (1..=5).map(f64::from).collect(); + let mut last = None; + for p in &series { + last = alma.update(*p); + } + let v = last.unwrap(); + let mean = series.iter().sum::() / series.len() as f64; + assert!(v > mean, "{v} should exceed {mean}"); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=100) + .map(|i| (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1) + .collect(); + let mut a = Alma::new(9, 0.85, 6.0).unwrap(); + let mut b = Alma::new(9, 0.85, 6.0).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut alma = Alma::new(9, 0.85, 6.0).unwrap(); + alma.batch(&(1..=40).map(f64::from).collect::>()); + assert!(alma.is_ready()); + alma.reset(); + assert!(!alma.is_ready()); + assert_eq!(alma.update(1.0), None); + } + + #[test] + fn ignores_non_finite_input() { + let mut alma = Alma::new(5, 0.85, 6.0).unwrap(); + alma.batch(&(1..=5).map(f64::from).collect::>()); + let before = alma.update(6.0).unwrap(); + // Non-finite inputs leave the window/current untouched. + assert_eq!(alma.update(f64::NAN), Some(before)); + assert_eq!(alma.update(f64::INFINITY), Some(before)); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index c555bd04..5d1ea2c6 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -7,6 +7,7 @@ mod accelerator_oscillator; mod adl; mod adx; +mod alma; mod aroon; mod aroon_oscillator; mod atr; @@ -79,6 +80,7 @@ mod zlema; pub use accelerator_oscillator::AcceleratorOscillator; pub use adl::Adl; pub use adx::{Adx, AdxOutput}; +pub use alma::Alma; pub use aroon::{Aroon, AroonOutput}; pub use aroon_oscillator::AroonOscillator; pub use atr::Atr; diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 4e5135b8..4fb4fc6d 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -44,7 +44,7 @@ pub mod indicators; pub use error::{Error, Result}; pub use indicators::{ - AcceleratorOscillator, Adl, Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, + AcceleratorOscillator, Adl, Adx, AdxOutput, Alma, Aroon, AroonOscillator, AroonOutput, Atr, AtrTrailingStop, AwesomeOscillator, BalanceOfPower, BollingerBands, BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, Cmo, Coppock, diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 09413680..6e3b3852 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -19,7 +19,7 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use std::hint::black_box; use wickra::{ - Atr, BatchExt, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Obv, Rsi, Sma, + Alma, Atr, BatchExt, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Obv, Rsi, Sma, Stochastic, Wma, }; use wickra_data::csv::CandleReader; @@ -139,6 +139,7 @@ fn benches(c: &mut Criterion) { bench_scalar(c, "ema", &closes, || Ema::new(14).unwrap()); bench_scalar(c, "wma", &closes, || Wma::new(14).unwrap()); bench_scalar(c, "rsi", &closes, || Rsi::new(14).unwrap()); + bench_scalar(c, "alma", &closes, || Alma::new(9, 0.85, 6.0).unwrap()); bench_macd(c, &closes); bench_bollinger(c, &closes); bench_candle_input(c, "atr", &candles, || Atr::new(14).unwrap()); diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index 7a4d3113..887ea647 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -15,10 +15,10 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - BatchExt, BollingerBands, Cmo, Coppock, Dema, Dpo, Ema, HistoricalVolatility, Hma, Indicator, - Kama, LinRegAngle, LinRegSlope, LinearRegression, MacdIndicator, Mom, Pmo, Ppo, Roc, Rsi, Sma, - Smma, StdDev, StochRsi, T3, Tema, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Wma, - ZScore, Zlema, + Alma, BatchExt, BollingerBands, Cmo, Coppock, Dema, Dpo, Ema, HistoricalVolatility, Hma, + Indicator, Kama, LinRegAngle, LinRegSlope, LinearRegression, MacdIndicator, Mom, Pmo, Ppo, Roc, + Rsi, Sma, Smma, StdDev, StochRsi, T3, Tema, Trima, Trix, Tsi, UlcerIndex, + VerticalHorizontalFilter, Wma, ZScore, Zlema, }; /// Drive a single streaming + batch run through one scalar indicator. Marked @@ -53,6 +53,7 @@ fuzz_target!(|data: Vec| { drive(|| Trima::new(14).unwrap(), &data); drive(|| Zlema::new(14).unwrap(), &data); drive(|| Kama::new(10, 2, 30).unwrap(), &data); + drive(|| Alma::new(9, 0.85, 6.0).unwrap(), &data); drive(|| T3::new(14, 0.7).unwrap(), &data); drive(|| Mom::new(14).unwrap(), &data); drive(|| Cmo::new(14).unwrap(), &data);