feat(alma): add Arnaud Legoux Moving Average
Gaussian-weighted moving average with configurable centre (offset in [0, 1]) and kernel width (sigma > 0). Pre-computes normalised weights at construction so each update is a single rolling window dot product. Reference: Arnaud Legoux and Dimitrios Kouzis-Loukas, 2009. Touchpoints: - crates/wickra-core: alma.rs + mod.rs + lib.rs re-export - bindings/python: PyAlma + __init__.py + test_new_indicators + test_known_values reference - bindings/node: AlmaNode + index.d.ts/index.js + indicators.test.js factory + reference value - bindings/wasm: wasm_scalar_indicator! macro - fuzz: indicator_update target covers ALMA(9, 0.85, 6.0) - crates/wickra/benches: bench_scalar entry - README + CHANGELOG: Moving Averages row + Unreleased entry
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Alma::new(period, offset, sigma).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
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::<PyTema>()?;
|
||||
m.add_class::<PyHma>()?;
|
||||
m.add_class::<PyKama>()?;
|
||||
m.add_class::<PyAlma>()?;
|
||||
m.add_class::<PyCci>()?;
|
||||
m.add_class::<PyRoc>()?;
|
||||
m.add_class::<PyWilliamsR>()?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,)),
|
||||
|
||||
Reference in New Issue
Block a user