feat(mcginley): add McGinley Dynamic moving average

John McGinley's self-adjusting moving average with the recurrence
MD + (price - MD) / (0.6 * period * (price / MD)^4). Speeds up when
price falls below the indicator and damps when price runs above the
indicator. Seeded with the simple average of the first period inputs.

Reference: McGinley, Technical Analysis of Stocks & Commodities, 1990.

Touchpoints:
- crates/wickra-core: mcginley_dynamic.rs + mod.rs + lib.rs re-export
- bindings/python: PyMcGinleyDynamic + __init__.py + test_new_indicators
  + test_known_values reference
- bindings/node: McGinleyDynamicNode (scalar macro) + index.d.ts/index.js
  + indicators.test.js factory + reference value
- bindings/wasm: wasm_scalar_indicator! macro
- fuzz: indicator_update target covers McGinleyDynamic(10)
- crates/wickra/benches: bench_scalar entry
- README + CHANGELOG: Moving Averages row + Unreleased entry
This commit is contained in:
kingchenc
2026-05-24 12:26:08 +02:00
parent 2454d7cf92
commit 3287146f44
15 changed files with 331 additions and 10 deletions
@@ -39,6 +39,7 @@ from ._wickra import (
T3,
VWMA,
ALMA,
McGinleyDynamic,
# Momentum
RSI,
MACD,
@@ -121,6 +122,7 @@ __all__ = [
"T3",
"VWMA",
"ALMA",
"McGinleyDynamic",
# Momentum
"RSI",
"MACD",
+53
View File
@@ -812,6 +812,58 @@ impl PyKama {
}
}
// ============================== McGinley Dynamic ==============================
#[pyclass(
name = "McGinleyDynamic",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyMcGinleyDynamic {
inner: wc::McGinleyDynamic,
}
#[pymethods]
impl PyMcGinleyDynamic {
#[new]
#[pyo3(signature = (period=10))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::McGinleyDynamic::new(period).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()
}
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!("McGinleyDynamic(period={})", self.inner.period())
}
}
// ============================== ALMA ==============================
#[pyclass(name = "ALMA", module = "wickra._wickra", skip_from_py_object)]
@@ -4556,6 +4608,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyHma>()?;
m.add_class::<PyKama>()?;
m.add_class::<PyAlma>()?;
m.add_class::<PyMcGinleyDynamic>()?;
m.add_class::<PyCci>()?;
m.add_class::<PyRoc>()?;
m.add_class::<PyWilliamsR>()?;
@@ -88,6 +88,23 @@ def test_alma_reference_value_period_3():
assert out[2] > 20.0
def test_mcginley_dynamic_constant_series_yields_the_constant():
# ratio = 1, so the recurrence collapses to MD + 0 / divisor = MD.
out = ta.McGinleyDynamic(5).batch(np.full(30, 42.0, dtype=np.float64))
assert np.all(np.isnan(out[:4]))
np.testing.assert_allclose(out[4:], 42.0, atol=1e-12)
def test_mcginley_dynamic_reference_value():
# Period 3, seed = SMA([10, 20, 30]) = 20.0. Next price 40.0:
# ratio = 2; divisor = 0.6 * 3 * 16 = 28.8; next = 20 + 20/28.8.
out = ta.McGinleyDynamic(3).batch(np.array([10.0, 20.0, 30.0, 40.0]))
assert math.isnan(out[0]) and math.isnan(out[1])
assert math.isclose(out[2], 20.0, abs_tol=1e-12)
expected = 20.0 + 20.0 / (0.6 * 3.0 * 16.0)
assert math.isclose(out[3], expected, abs_tol=1e-12)
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.
@@ -45,6 +45,7 @@ SCALAR = [
(ta.TRIMA, (20,)),
(ta.ZLEMA, (14,)),
(ta.ALMA, (9, 0.85, 6.0)),
(ta.McGinleyDynamic, (10,)),
(ta.T3, (5, 0.7)),
(ta.MOM, (10,)),
(ta.CMO, (14,)),