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:
@@ -0,0 +1,224 @@
|
||||
//! `McGinley` Dynamic — self-adjusting moving average.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// John `McGinley`'s "Dynamic" — a self-adjusting moving average that speeds up
|
||||
/// in downtrends and slows down in uptrends to track price more closely than
|
||||
/// a fixed-period MA.
|
||||
///
|
||||
/// The recurrence is
|
||||
///
|
||||
/// ```text
|
||||
/// MD_t = MD_{t-1} + (price_t - MD_{t-1}) / (K * period * (price_t / MD_{t-1})^4)
|
||||
/// ```
|
||||
///
|
||||
/// where `K = 0.6` is `McGinley`'s original constant. The fourth-power ratio
|
||||
/// term shrinks the divisor when price falls below the indicator (faster
|
||||
/// catch-up) and inflates it when price runs above (more smoothing). The
|
||||
/// indicator is seeded with the simple average of the first `period` inputs.
|
||||
///
|
||||
/// Reference: John R. `McGinley` Jr., *Technical Analysis of Stocks &
|
||||
/// Commodities*, 1990.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, McGinleyDynamic};
|
||||
///
|
||||
/// let mut md = McGinleyDynamic::new(10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = md.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McGinleyDynamic {
|
||||
period: usize,
|
||||
seed: VecDeque<f64>,
|
||||
seed_sum: f64,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
/// `McGinley`'s original constant `K` in the recurrence denominator.
|
||||
const K: f64 = 0.6;
|
||||
|
||||
impl McGinleyDynamic {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
seed: VecDeque::with_capacity(period),
|
||||
seed_sum: 0.0,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for McGinleyDynamic {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.current;
|
||||
}
|
||||
if let Some(prev) = self.current {
|
||||
// The recurrence divides by `(price / prev)^4`; if either side is
|
||||
// zero or negative the formula blows up, so we hold the previous
|
||||
// value as a defensive fallback against degenerate price series.
|
||||
if prev <= 0.0 || input <= 0.0 {
|
||||
return self.current;
|
||||
}
|
||||
let ratio = input / prev;
|
||||
let divisor = K * (self.period as f64) * ratio.powi(4);
|
||||
let next = prev + (input - prev) / divisor;
|
||||
self.current = Some(next);
|
||||
} else {
|
||||
self.seed.push_back(input);
|
||||
self.seed_sum += input;
|
||||
if self.seed.len() == self.period {
|
||||
self.current = Some(self.seed_sum / self.period as f64);
|
||||
}
|
||||
}
|
||||
self.current
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.seed.clear();
|
||||
self.seed_sum = 0.0;
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"McGinleyDynamic"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(McGinleyDynamic::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut md = McGinleyDynamic::new(10).unwrap();
|
||||
assert_eq!(md.period(), 10);
|
||||
assert_eq!(md.warmup_period(), 10);
|
||||
assert_eq!(md.name(), "McGinleyDynamic");
|
||||
assert_eq!(md.value(), None);
|
||||
for i in 1..=10 {
|
||||
md.update(f64::from(i));
|
||||
}
|
||||
assert!(md.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_the_constant() {
|
||||
// ratio = 1, so the recurrence collapses to MD + 0 / divisor = MD.
|
||||
let mut md = McGinleyDynamic::new(5).unwrap();
|
||||
let out = md.batch(&[42.0_f64; 30]);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 42.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_period() {
|
||||
let mut md = McGinleyDynamic::new(3).unwrap();
|
||||
// Seed = SMA([10, 20, 30]) = 20.0.
|
||||
assert_eq!(md.update(10.0), None);
|
||||
assert_eq!(md.update(20.0), None);
|
||||
assert_eq!(md.update(30.0), Some(20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value_recurrence() {
|
||||
// Period 3, seed = SMA([10, 20, 30]) = 20.0. Then on price = 40.0:
|
||||
// ratio = 40 / 20 = 2
|
||||
// divisor = 0.6 * 3 * 2^4 = 0.6 * 3 * 16 = 28.8
|
||||
// next = 20 + (40 - 20) / 28.8 = 20.694444...
|
||||
let mut md = McGinleyDynamic::new(3).unwrap();
|
||||
md.batch(&[10.0_f64, 20.0, 30.0]);
|
||||
let v = md.update(40.0).unwrap();
|
||||
let expected = 20.0 + 20.0 / (0.6 * 3.0 * 16.0);
|
||||
assert_relative_eq!(v, expected, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = McGinleyDynamic::new(10).unwrap();
|
||||
let mut b = McGinleyDynamic::new(10).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut md = McGinleyDynamic::new(5).unwrap();
|
||||
md.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(md.is_ready());
|
||||
md.reset();
|
||||
assert!(!md.is_ready());
|
||||
assert_eq!(md.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut md = McGinleyDynamic::new(3).unwrap();
|
||||
md.batch(&[10.0_f64, 20.0, 30.0]);
|
||||
let before = md.value().unwrap();
|
||||
assert_eq!(md.update(f64::NAN), Some(before));
|
||||
assert_eq!(md.update(f64::INFINITY), Some(before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holds_value_when_input_is_non_positive() {
|
||||
// Defensive: a zero or negative price would make the (price/prev)^4
|
||||
// divisor zero or otherwise blow up; the recurrence holds steady.
|
||||
let mut md = McGinleyDynamic::new(3).unwrap();
|
||||
md.batch(&[10.0_f64, 20.0, 30.0]);
|
||||
let before = md.value().unwrap();
|
||||
assert_eq!(md.update(0.0), Some(before));
|
||||
assert_eq!(md.update(-5.0), Some(before));
|
||||
// Once a positive price arrives the recurrence resumes normally.
|
||||
let after = md.update(40.0).unwrap();
|
||||
assert!(after > before);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ mod linreg_angle;
|
||||
mod linreg_slope;
|
||||
mod macd;
|
||||
mod mass_index;
|
||||
mod mcginley_dynamic;
|
||||
mod median_price;
|
||||
mod mfi;
|
||||
mod mom;
|
||||
@@ -113,6 +114,7 @@ pub use linreg_angle::LinRegAngle;
|
||||
pub use linreg_slope::LinRegSlope;
|
||||
pub use macd::{MacdIndicator, MacdOutput};
|
||||
pub use mass_index::MassIndex;
|
||||
pub use mcginley_dynamic::McGinleyDynamic;
|
||||
pub use median_price::MedianPrice;
|
||||
pub use mfi::Mfi;
|
||||
pub use mom::Mom;
|
||||
|
||||
@@ -50,9 +50,9 @@ pub use indicators::{
|
||||
ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, Cmo, Coppock,
|
||||
Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema, ForceIndex, HistoricalVolatility,
|
||||
Hma, Kama, Keltner, KeltnerOutput, LinRegAngle, LinRegSlope, LinearRegression, MacdIndicator,
|
||||
MacdOutput, MassIndex, MedianPrice, Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc,
|
||||
RollingVwap, Rsi, Sma, Smma, StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend,
|
||||
SuperTrendOutput, Tema, Trima, Trix, TrueRange, Tsi, TypicalPrice, UlcerIndex,
|
||||
MacdOutput, MassIndex, McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo,
|
||||
Psar, Roc, RollingVwap, Rsi, Sma, Smma, StdDev, StochRsi, Stochastic, StochasticOutput,
|
||||
SuperTrend, SuperTrendOutput, Tema, Trima, Trix, TrueRange, Tsi, TypicalPrice, UlcerIndex,
|
||||
UltimateOscillator, VerticalHorizontalFilter, VolumePriceTrend, Vortex, VortexOutput, Vwap,
|
||||
Vwma, WeightedClose, WilliamsR, Wma, ZScore, Zlema, T3,
|
||||
};
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||
use std::hint::black_box;
|
||||
use wickra::{
|
||||
Alma, Atr, BatchExt, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Obv, Rsi, Sma,
|
||||
Stochastic, Wma,
|
||||
Alma, Atr, BatchExt, BollingerBands, Candle, Ema, Indicator, MacdIndicator, McGinleyDynamic,
|
||||
Obv, Rsi, Sma, Stochastic, Wma,
|
||||
};
|
||||
use wickra_data::csv::CandleReader;
|
||||
|
||||
@@ -140,6 +140,9 @@ fn benches(c: &mut Criterion) {
|
||||
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_scalar(c, "mcginley_dynamic", &closes, || {
|
||||
McGinleyDynamic::new(10).unwrap()
|
||||
});
|
||||
bench_macd(c, &closes);
|
||||
bench_bollinger(c, &closes);
|
||||
bench_candle_input(c, "atr", &candles, || Atr::new(14).unwrap());
|
||||
|
||||
Reference in New Issue
Block a user