Files
wickra/crates/wickra-core/src/indicators/jma.rs
T
kingchencandGitHub 466faddd87 feat: Family 01 Moving Averages — ALMA / McGinley / FRAMA / VIDYA / JMA / Alligator / EVWMA (#39)
* 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

* 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

* feat(frama): add Fractal Adaptive Moving Average

Ehlers' FRAMA adapts its smoothing constant to the fractal dimension of
the recent window: tight tracking in trends, heavy smoothing in chop.
Uses the close-only variant where max/min over each window half drive
the dimension estimate. Period must be even (default 16).

Reference: Ehlers, Fractal Adaptive Moving Average, 2005.

Touchpoints:
- crates/wickra-core: frama.rs + mod.rs + lib.rs re-export
- bindings/python: PyFrama + __init__.py + test_new_indicators +
  test_known_values reference (constant series + uptrend tracking)
- bindings/node: FramaNode (scalar macro) + index.d.ts/index.js +
  indicators.test.js factory + reference value
- bindings/wasm: wasm_scalar_indicator! macro
- fuzz: indicator_update target covers Frama(16)
- crates/wickra/benches: bench_scalar entry
- README + CHANGELOG: Moving Averages row + Unreleased entry

* feat(vidya): add Variable Index Dynamic Average

Chande's VIDYA — an EMA whose alpha scales with |CMO(cmo_period)| / 100.
Strong directional momentum lifts the smoothing constant toward the
EMA-of-period rate; flat or choppy windows shrink it toward zero so
VIDYA coasts on its previous value. Two parameters: period (14) and
cmo_period (9). Reuses the existing wickra-core Cmo internally.

Reference: Chande, Stocks & Commodities, 1992.

Also fixes a silent gap from d37fbd1 (feat(frama)): the PyFrama Python
class wrapper and its add_class registration were dropped because the
two edits hit "File has not been read yet" errors that scrolled past
in a batch. Adds them here alongside VIDYA's bindings.

Touchpoints (VIDYA): vidya.rs + mod.rs + lib.rs re-export, PyVidya +
__init__.py + test_new_indicators + test_known_values reference,
VidyaNode (manual two-param binding) + index.d.ts/index.js +
indicators.test.js factory + reference, wasm_scalar_indicator! macro,
fuzz target, bench, README + CHANGELOG.

* feat(jma): add Jurik Moving Average

Three-stage filter reconstruction of Mark Jurik's adaptive MA (the
algorithm is proprietary; this is the form used by most open-source
ports since the 1999 TASC article). Parameters: period (14), phase in
[-100, 100] (0), power in 1..=4 (2). State is seeded by setting
e0 = JMA = first input so a constant input stream is reproduced exactly.

Touchpoints: jma.rs + mod.rs + lib.rs re-export, PyJma + __init__.py +
test_new_indicators + test_known_values reference, JmaNode (manual
three-param binding) + index.d.ts/index.js + indicators.test.js factory
+ reference, wasm_scalar_indicator! macro, fuzz target, bench, README +
CHANGELOG.

* feat(alligator): add Bill Williams Alligator

Three SMMA lines (Jaw / Teeth / Lips) over the median price
(high + low) / 2 with default periods 13 / 8 / 5. Multi-output
indicator returning AlligatorOutput { jaw, teeth, lips }. The
original chart variant shifts each line forward for display; we
publish the unshifted SMMA values and leave the visual shift to
the consumer.

Reference: Bill Williams, Trading Chaos, 1995.

Touchpoints: alligator.rs + mod.rs + lib.rs re-export, PyAlligator
(Candle input, returns 3-tuple, ndarray (n, 3) batch) + __init__.py
+ test_new_indicators + test_known_values reference, AlligatorNode +
AlligatorValue + index.d.ts/index.js + indicators.test.js multi
factory + reference, WasmAlligator (manual JsValue object) +
candle-fuzz target + README + CHANGELOG.

* feat(evwma): add Elastic Volume-Weighted Moving Average

Christian P. Fries' elastic recurrence where the smoothing weight is the
bar's volume relative to the running window total:

  V_sum_t = sum of volumes over the last period candles
  EVWMA_t = ((V_sum_t - v_t) * EVWMA_{t-1} + v_t * close_t) / V_sum_t

A bar whose volume is small barely moves the average; a bar that
dominates the window pulls it strongly toward that bar's close. Seeded
with the close of the first full window; holds its previous value if
the entire window has zero volume.

Reference: Fries, Wilmott Magazine, 2001.

Touchpoints: evwma.rs + mod.rs + lib.rs re-export, PyEvwma (close +
volume batch) + __init__.py + test_new_indicators CANDLE_SCALAR +
test_known_values reference, EvwmaNode + index.d.ts/index.js +
indicators.test.js candleScalar factory + reference, WasmEvwma,
candle-fuzz target + README + CHANGELOG.

* ci: Force local wheel install in Python jobs

Use --no-index --no-deps so the Python matrix installs the freshly
built wheel from dist/ and never falls back to PyPI. Previously pip
sometimes picked the released 0.2.x wheel on macOS / Windows when its
platform tag was a wider match than the local build, which made the
job test the released package and miss any new symbols added in the
PR (e.g. AttributeError: module 'wickra' has no attribute 'ALMA').
numpy is already installed by the preceding pip step, so --no-deps
is safe.
2026-05-25 15:01:14 +02:00

287 lines
8.7 KiB
Rust

//! Jurik Moving Average (JMA).
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Mark Jurik's adaptive moving average. The original algorithm is proprietary
/// and Jurik Research has never published the full source. This implementation
/// follows the widely-used three-stage filter reconstruction circulated since
/// the 1999 TASC article on the indicator — the same form used by most
/// open-source ports (`TradingView` Pine, `pandas-ta`, various MQL ports):
///
/// ```text
/// beta = 0.45 * (period - 1) / (0.45 * (period - 1) + 2)
/// alpha = beta ^ power
/// phase_ratio = clamp(phase / 100 + 1.5, 0.5, 2.5)
///
/// e0_t = (1 - alpha) * x_t + alpha * e0_{t-1}
/// e1_t = (x_t - e0_t) * (1 - beta) + beta * e1_{t-1}
/// e2_t = (e0_t + phase_ratio * e1_t - JMA_{t-1}) * (1 - alpha)^2 + alpha^2 * e2_{t-1}
/// JMA_t = JMA_{t-1} + e2_t
/// ```
///
/// The state is seeded by setting `e0 = JMA = first input`, so a constant
/// input stream is reproduced exactly from the first output onward.
///
/// # Parameters
///
/// - `period`: smoothing length (default 14).
/// - `phase`: phase shift in `[-100, 100]`. Values outside this range are
/// clamped to the boundary `phase_ratio` so the constructor never fails on
/// a finite `phase`.
/// - `power`: kernel exponent in `1..=4` (default 2 matches the popular
/// reconstruction).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Jma};
///
/// let mut jma = Jma::new(14, 0.0, 2).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = jma.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Jma {
period: usize,
phase: f64,
power: u32,
beta: f64,
alpha: f64,
phase_ratio: f64,
e0: f64,
e1: f64,
e2: f64,
output: Option<f64>,
}
impl Jma {
/// # Errors
/// - [`Error::PeriodZero`] if `period == 0`.
/// - [`Error::InvalidPeriod`] if `phase` is non-finite or `power` is
/// outside `1..=4`.
pub fn new(period: usize, phase: f64, power: u32) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !phase.is_finite() {
return Err(Error::InvalidPeriod {
message: "JMA phase must be a finite value",
});
}
if !(1..=4).contains(&power) {
return Err(Error::InvalidPeriod {
message: "JMA power must be in 1..=4",
});
}
let len = period as f64 - 1.0;
let beta = 0.45 * len / (0.45 * len + 2.0);
let alpha = beta.powi(i32::try_from(power).expect("power is in 1..=4"));
let phase_ratio = (phase / 100.0 + 1.5).clamp(0.5, 2.5);
Ok(Self {
period,
phase,
power,
beta,
alpha,
phase_ratio,
e0: 0.0,
e1: 0.0,
e2: 0.0,
output: None,
})
}
/// Construct JMA with the popular defaults `(period = 14, phase = 0, power = 2)`.
pub fn classic() -> Self {
Self::new(14, 0.0, 2).expect("classic JMA parameters are valid")
}
/// Configured `(period, phase, power)`.
pub const fn params(&self) -> (usize, f64, u32) {
(self.period, self.phase, self.power)
}
}
impl Indicator for Jma {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.output;
}
let Some(prev_jma) = self.output else {
// Seed e0 and JMA to the first input so a flat series is
// reproduced exactly.
self.e0 = input;
self.output = Some(input);
return self.output;
};
self.e0 = (1.0 - self.alpha) * input + self.alpha * self.e0;
self.e1 = (input - self.e0) * (1.0 - self.beta) + self.beta * self.e1;
let one_minus_alpha = 1.0 - self.alpha;
self.e2 =
(self.e0 + self.phase_ratio * self.e1 - prev_jma) * one_minus_alpha * one_minus_alpha
+ self.alpha * self.alpha * self.e2;
let next = prev_jma + self.e2;
self.output = Some(next);
Some(next)
}
fn reset(&mut self) {
self.e0 = 0.0;
self.e1 = 0.0;
self.e2 = 0.0;
self.output = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.output.is_some()
}
fn name(&self) -> &'static str {
"JMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Jma::new(0, 0.0, 2), Err(Error::PeriodZero)));
}
#[test]
fn rejects_non_finite_phase() {
assert!(matches!(
Jma::new(14, f64::NAN, 2),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Jma::new(14, f64::INFINITY, 2),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn rejects_invalid_power() {
assert!(matches!(
Jma::new(14, 0.0, 0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Jma::new(14, 0.0, 5),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let jma = Jma::new(14, 0.0, 2).unwrap();
assert_eq!(jma.params(), (14, 0.0, 2));
assert_eq!(jma.warmup_period(), 1);
assert_eq!(jma.name(), "JMA");
}
#[test]
fn classic_factory() {
let jma = Jma::classic();
assert_eq!(jma.params(), (14, 0.0, 2));
}
#[test]
fn constant_series_yields_the_constant() {
// Seeding e0 = JMA = first input means the recurrence stays exactly
// on the constant from the very first sample.
let mut jma = Jma::new(14, 0.0, 2).unwrap();
let out = jma.batch(&[42.0_f64; 60]);
for x in out.iter().flatten() {
assert_relative_eq!(*x, 42.0, epsilon = 1e-12);
}
}
#[test]
fn extreme_phase_is_clamped() {
// phase outside [-100, 100] must produce a finite JMA series (phase
// ratio clamps to [0.5, 2.5]) rather than blow up the recurrence.
let mut a = Jma::new(14, 250.0, 2).unwrap();
let mut b = Jma::new(14, -250.0, 2).unwrap();
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
for &p in &prices {
let va = a.update(p).unwrap();
let vb = b.update(p).unwrap();
assert!(va.is_finite(), "JMA(phase=+250) emitted {va}");
assert!(vb.is_finite(), "JMA(phase=-250) emitted {vb}");
}
}
#[test]
fn pure_uptrend_tracks_close() {
// Monotonic uptrend, period 5, power 2 — after enough samples the
// smoothed JMA sits close to the latest input.
let mut jma = Jma::new(5, 0.0, 2).unwrap();
let prices: Vec<f64> = (1..=80).map(f64::from).collect();
let out = jma.batch(&prices);
let last = out.last().unwrap().unwrap();
let latest = *prices.last().unwrap();
assert!(
(latest - last).abs() < 5.0,
"JMA on a long clean uptrend should track close: {last} vs {latest}"
);
}
#[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 = Jma::new(14, 0.0, 2).unwrap();
let mut b = Jma::new(14, 0.0, 2).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut jma = Jma::new(14, 0.0, 2).unwrap();
jma.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
assert!(jma.is_ready());
jma.reset();
assert!(!jma.is_ready());
assert_eq!(jma.e0, 0.0);
}
#[test]
fn ignores_non_finite_input() {
let mut jma = Jma::new(14, 0.0, 2).unwrap();
jma.batch(&(1..=15).map(f64::from).collect::<Vec<_>>());
let before = jma.update(16.0).unwrap();
assert_eq!(jma.update(f64::NAN), Some(before));
assert_eq!(jma.update(f64::INFINITY), Some(before));
}
#[test]
fn period_one_is_pass_through() {
// beta = 0, alpha = 0 -> e2 collapses to (input - prev) and the
// recurrence reduces to JMA_t = input.
let mut jma = Jma::new(1, 0.0, 2).unwrap();
assert_eq!(jma.update(5.0), Some(5.0));
assert_relative_eq!(jma.update(10.0).unwrap(), 10.0, epsilon = 1e-12);
assert_relative_eq!(jma.update(7.0).unwrap(), 7.0, epsilon = 1e-12);
}
}