diff --git a/CHANGELOG.md b/CHANGELOG.md index 22ed5a4e..1f47d617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `[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). +- **Family 01 — Moving Averages.** `FRAMA` (Fractal Adaptive Moving + Average, Ehlers 2005): adapts its smoothing constant to the fractal + dimension of the recent window — fast in trends, slow in chop. Single + parameter `period` (must be even, default 16). Exposed in all four + bindings. - **Family 01 — Moving Averages.** `McGinleyDynamic`: John McGinley's self-adjusting MA. Single parameter `period`; the recurrence `MD + (price - MD) / (0.6 * period * (price / MD)^4)` speeds up when price diff --git a/README.md b/README.md index 275ce97e..d66f751e 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,13 @@ python -m benchmarks.compare_libraries ## Indicators -73 streaming-first indicators across eight families. Every one passes the +74 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, ALMA, McGinley Dynamic | +| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA | | 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 82fa1f40..2008032b 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -39,6 +39,7 @@ const scalarFactories = { KAMA: () => new wickra.KAMA(10, 2, 30), ALMA: () => new wickra.ALMA(9, 0.85, 6.0), McGinleyDynamic: () => new wickra.McGinleyDynamic(10), + FRAMA: () => new wickra.FRAMA(16), SMMA: () => new wickra.SMMA(14), TRIMA: () => new wickra.TRIMA(20), ZLEMA: () => new wickra.ZLEMA(14), @@ -261,6 +262,11 @@ test('LinRegAngle of a unit-slope series is 45 degrees', () => { assert.ok(Math.abs(out[4] - 45) < 1e-9); }); +test('FRAMA pure uptrend hugs the latest close', () => { + const out = new wickra.FRAMA(4).batch([1, 2, 3, 4, 5, 6, 7, 8]); + assert.ok(Math.abs(out[out.length - 1] - 8) < 0.05); +}); + test('McGinleyDynamic(3) seeds with SMA and recurses on the next price', () => { // Seed = SMA([10, 20, 30]) = 20. On 40: ratio = 2, divisor = 0.6*3*16 = 28.8. const out = new wickra.McGinleyDynamic(3).batch([10, 20, 30, 40]); diff --git a/bindings/node/index.js b/bindings/node/index.js index 3617a0ca..90c92cd7 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, ALMA, McGinleyDynamic, 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, McGinleyDynamic, FRAMA, 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 @@ -351,6 +351,7 @@ module.exports.Aroon = Aroon module.exports.KAMA = KAMA module.exports.ALMA = ALMA module.exports.McGinleyDynamic = McGinleyDynamic +module.exports.FRAMA = FRAMA 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 4c39206b..9fa505c0 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -117,6 +117,7 @@ node_scalar_indicator!( ); node_scalar_indicator!(ZScoreNode, "ZScore", wc::ZScore); node_scalar_indicator!(McGinleyDynamicNode, "McGinleyDynamic", wc::McGinleyDynamic); +node_scalar_indicator!(FramaNode, "FRAMA", wc::Frama); // ============================== MACD ============================== diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index bee22843..bd27c67b 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -40,6 +40,7 @@ from ._wickra import ( VWMA, ALMA, McGinleyDynamic, + FRAMA, # Momentum RSI, MACD, @@ -123,6 +124,7 @@ __all__ = [ "VWMA", "ALMA", "McGinleyDynamic", + "FRAMA", # Momentum "RSI", "MACD", diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index 5946f386..1807ab8f 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -105,6 +105,20 @@ def test_mcginley_dynamic_reference_value(): assert math.isclose(out[3], expected, abs_tol=1e-12) +def test_frama_constant_series_yields_the_constant(): + # Flat input -> degenerate ranges -> alpha clamps to 0.01 and the EMA + # recurrence holds the seed value. + out = ta.FRAMA(4).batch(np.full(20, 42.0, dtype=np.float64)) + assert np.all(np.isnan(out[:3])) + np.testing.assert_allclose(out[3:], 42.0, atol=1e-12) + + +def test_frama_pure_uptrend_hugs_latest(): + # Monotonic uptrend -> alpha pushed toward 1.0, FRAMA tracks close. + out = ta.FRAMA(4).batch(np.arange(1.0, 9.0, dtype=np.float64)) + assert math.isclose(out[-1], 8.0, abs_tol=0.05) + + 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 2804a2b1..d0bce020 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -46,6 +46,7 @@ SCALAR = [ (ta.ZLEMA, (14,)), (ta.ALMA, (9, 0.85, 6.0)), (ta.McGinleyDynamic, (10,)), + (ta.FRAMA, (16,)), (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 03d5c8b1..2256e968 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -81,6 +81,7 @@ 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!(WasmMcGinleyDynamic, "McGinleyDynamic", wc::McGinleyDynamic, period: usize); +wasm_scalar_indicator!(WasmFrama, "FRAMA", wc::Frama, period: usize); 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/frama.rs b/crates/wickra-core/src/indicators/frama.rs new file mode 100644 index 00000000..6f6c926c --- /dev/null +++ b/crates/wickra-core/src/indicators/frama.rs @@ -0,0 +1,259 @@ +//! Fractal Adaptive Moving Average (FRAMA). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' Fractal Adaptive Moving Average. +/// +/// FRAMA picks its smoothing constant from the fractal dimension `D` of the +/// recent window: in a trending (low-`D`) market it follows price tightly, in +/// a choppy (high-`D`) market it smooths heavily. The window of `period` +/// closes is split into two equal halves; the fractal dimension comes from +/// the price ranges of the halves vs. the whole window: +/// +/// ```text +/// N1 = (max(first half) - min(first half)) / (period / 2) +/// N2 = (max(second half) - min(second half)) / (period / 2) +/// N3 = (max(window) - min(window)) / period +/// D = (log(N1 + N2) - log(N3)) / log(2) +/// alpha = exp(-4.6 * (D - 1)) clamped to [0.01, 1.0] +/// ``` +/// +/// The output is an EMA-like recurrence +/// `FRAMA_t = alpha * close_t + (1 - alpha) * FRAMA_{t - 1}`, seeded with the +/// first close. `period` must be even and at least 2. +/// +/// Reference: John F. Ehlers, *Fractal Adaptive Moving Average*, 2005. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Frama, Indicator}; +/// +/// let mut frama = Frama::new(16).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = frama.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Frama { + period: usize, + half: usize, + window: VecDeque, + current: Option, +} + +impl Frama { + /// # Errors + /// - [`Error::PeriodZero`] if `period == 0`. + /// - [`Error::InvalidPeriod`] if `period` is odd or below 2. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if period < 2 { + return Err(Error::InvalidPeriod { + message: "FRAMA period must be at least 2", + }); + } + if period % 2 != 0 { + return Err(Error::InvalidPeriod { + message: "FRAMA period must be even", + }); + } + Ok(Self { + period, + half: period / 2, + window: VecDeque::with_capacity(period), + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Frama { + 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 half = self.half; + let mut h_first = f64::NEG_INFINITY; + let mut l_first = f64::INFINITY; + let mut h_second = f64::NEG_INFINITY; + let mut l_second = f64::INFINITY; + let mut h_whole = f64::NEG_INFINITY; + let mut l_whole = f64::INFINITY; + for (i, &p) in self.window.iter().enumerate() { + if p > h_whole { + h_whole = p; + } + if p < l_whole { + l_whole = p; + } + if i < half { + if p > h_first { + h_first = p; + } + if p < l_first { + l_first = p; + } + } else { + if p > h_second { + h_second = p; + } + if p < l_second { + l_second = p; + } + } + } + + let half_f = half as f64; + let period_f = self.period as f64; + let n1 = (h_first - l_first) / half_f; + let n2 = (h_second - l_second) / half_f; + let n3 = (h_whole - l_whole) / period_f; + + let alpha = if n1 > 0.0 && n2 > 0.0 && n3 > 0.0 { + let d = ((n1 + n2).ln() - n3.ln()) / 2.0_f64.ln(); + (-4.6 * (d - 1.0)).exp().clamp(0.01, 1.0) + } else { + // Degenerate (perfectly flat half or whole window): use the slowest + // smoothing so the indicator coasts on its previous value. + 0.01 + }; + + let prev = self.current.unwrap_or(input); + let next = alpha * input + (1.0 - alpha) * prev; + self.current = Some(next); + Some(next) + } + + 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 { + "FRAMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Frama::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_invalid_period() { + assert!(matches!(Frama::new(1), Err(Error::InvalidPeriod { .. }))); + assert!(matches!(Frama::new(3), Err(Error::InvalidPeriod { .. }))); + assert!(matches!(Frama::new(15), Err(Error::InvalidPeriod { .. }))); + } + + #[test] + fn accessors_and_metadata() { + let frama = Frama::new(16).unwrap(); + assert_eq!(frama.period(), 16); + assert_eq!(frama.warmup_period(), 16); + assert_eq!(frama.name(), "FRAMA"); + } + + #[test] + fn constant_series_yields_the_constant() { + // Flat input -> alpha clamps to 0.01 (degenerate ranges) and the + // EMA recurrence holds the seed value forever. + let mut frama = Frama::new(4).unwrap(); + let out = frama.batch(&[42.0_f64; 30]); + for v in out.iter().skip(3).flatten() { + assert_relative_eq!(*v, 42.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_first_value_at_period() { + let mut frama = Frama::new(4).unwrap(); + assert_eq!(frama.update(1.0), None); + assert_eq!(frama.update(2.0), None); + assert_eq!(frama.update(3.0), None); + assert!(frama.update(4.0).is_some()); + } + + #[test] + fn pure_uptrend_alpha_close_to_one() { + // A strict monotonic uptrend has fractal dimension ~1, so alpha is + // pushed to 1.0 and FRAMA reduces to the latest price. + let mut frama = Frama::new(4).unwrap(); + let prices: Vec = (1..=8).map(f64::from).collect(); + let out = frama.batch(&prices); + let last = out.last().unwrap().unwrap(); + assert!( + (last - 8.0).abs() < 0.05, + "FRAMA on a clean uptrend should hug the latest close: {last}" + ); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = Frama::new(8).unwrap(); + let mut b = Frama::new(8).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut frama = Frama::new(4).unwrap(); + frama.batch(&(1..=20).map(f64::from).collect::>()); + assert!(frama.is_ready()); + frama.reset(); + assert!(!frama.is_ready()); + assert_eq!(frama.update(1.0), None); + } + + #[test] + fn ignores_non_finite_input() { + let mut frama = Frama::new(4).unwrap(); + frama.batch(&[1.0, 2.0, 3.0, 4.0]); + let before = frama.update(5.0).unwrap(); + assert_eq!(frama.update(f64::NAN), Some(before)); + assert_eq!(frama.update(f64::INFINITY), Some(before)); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index ab41ca66..627fd3fa 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -31,6 +31,7 @@ mod dpo; mod ease_of_movement; mod ema; mod force_index; +mod frama; mod historical_volatility; mod hma; mod kama; @@ -105,6 +106,7 @@ pub use dpo::Dpo; pub use ease_of_movement::EaseOfMovement; pub use ema::Ema; pub use force_index::ForceIndex; +pub use frama::Frama; pub use historical_volatility::HistoricalVolatility; pub use hma::Hma; pub use kama::Kama; diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 424cb759..23762f67 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -48,13 +48,13 @@ pub use indicators::{ AtrTrailingStop, AwesomeOscillator, BalanceOfPower, BollingerBands, BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, Cmo, Coppock, - Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema, ForceIndex, HistoricalVolatility, - Hma, Kama, Keltner, KeltnerOutput, LinRegAngle, LinRegSlope, LinearRegression, MacdIndicator, - 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, + Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema, ForceIndex, Frama, + HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, LinRegAngle, LinRegSlope, + LinearRegression, MacdIndicator, 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, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 1ed0439a..87c88d3c 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -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, McGinleyDynamic, - Obv, Rsi, Sma, Stochastic, Wma, + Alma, Atr, BatchExt, BollingerBands, Candle, Ema, Frama, Indicator, MacdIndicator, + McGinleyDynamic, Obv, Rsi, Sma, Stochastic, Wma, }; use wickra_data::csv::CandleReader; @@ -143,6 +143,7 @@ fn benches(c: &mut Criterion) { bench_scalar(c, "mcginley_dynamic", &closes, || { McGinleyDynamic::new(10).unwrap() }); + bench_scalar(c, "frama", &closes, || Frama::new(16).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 74b4e17f..7f79da93 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -15,7 +15,7 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - Alma, BatchExt, BollingerBands, Cmo, Coppock, Dema, Dpo, Ema, HistoricalVolatility, Hma, + Alma, BatchExt, BollingerBands, Cmo, Coppock, Dema, Dpo, Ema, Frama, HistoricalVolatility, Hma, Indicator, Kama, LinRegAngle, LinRegSlope, LinearRegression, MacdIndicator, McGinleyDynamic, Mom, Pmo, Ppo, Roc, Rsi, Sma, Smma, StdDev, StochRsi, T3, Tema, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Wma, ZScore, Zlema, @@ -55,6 +55,7 @@ fuzz_target!(|data: Vec| { drive(|| Kama::new(10, 2, 30).unwrap(), &data); drive(|| Alma::new(9, 0.85, 6.0).unwrap(), &data); drive(|| McGinleyDynamic::new(10).unwrap(), &data); + drive(|| Frama::new(16).unwrap(), &data); drive(|| T3::new(14, 0.7).unwrap(), &data); drive(|| Mom::new(14).unwrap(), &data); drive(|| Cmo::new(14).unwrap(), &data);