From 7a18a26dafffcda8995127321589500b67b0ff7b Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 25 May 2026 22:14:27 +0200 Subject: [PATCH] feat(family-10): add 16 Ehlers / Cycle (DSP) indicators (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Family 10 (Ehlers / Cycle) end-to-end across Rust core, Python / Node / WASM bindings, fuzz, tests, benches and docs. This is an entirely new family covering John Ehlers' digital-signal- processing school of cycle analytics — a strong differentiator versus TA-Lib and pandas-ta, which ship only fragments. Indicators: - MAMA (Mesa Adaptive MA) — multi-output { mama, fama } - FAMA (Following Adaptive MA) — scalar wrapper around MAMA's slow line - Fisher Transform — Gaussian-normalising price transform - Inverse Fisher Transform — bounded oscillator (tanh-based) - SuperSmoother — 2-pole Butterworth lowpass - Roofing Filter — high-pass + SuperSmoother bandpass - Decycler — price minus 2-pole high-pass (lag-free trend) - Decycler Oscillator — fast / slow Decycler difference (MACD-like) - Hilbert Dominant Cycle — phase-derived period estimator [6, 50] - Sine Wave Indicator — sin(phase) with 45° lead companion - Adaptive Cycle Indicator — half-period driver for adaptive oscillators - Center of Gravity Oscillator — weighted-mass momentum - Cybernetic Cycle Component — EasyLanguage classic - Empirical Mode Decomposition — bandpass + envelope mean - Ehlers Stochastic — Stochastic on Roofing Filter input, [-1, +1] - Instantaneous Trendline — Ehlers 2-pole lag-free trend Indicator count rises 71 -> 87 across nine families (was eight). All sixteen pass batch == streaming equivalence, expose the standard Indicator surface (update / batch / reset / is_ready / warmup_period / name), are fuzz-tested, benchmarked against the checked-in BTCUSDT 1-minute dataset and reach across all four bindings. Wiki deep-dive drafts for every indicator + Sidebar / Overview / Home / Warmup updates are staged under indicator-ideas/families/ wiki/family-10-ehlers-cycle/ in the main repo (ghost-ignored) for the maintainer to publish to the wiki repo manually. --- CHANGELOG.md | 27 + README.md | 3 +- bindings/node/__tests__/indicators.test.js | 18 + bindings/node/index.js | 18 +- bindings/node/src/lib.rs | 357 ++++++++++++ bindings/python/python/wickra/__init__.py | 34 ++ bindings/python/src/lib.rs | 524 ++++++++++++++++++ .../python/tests/test_input_validation.py | 17 + bindings/python/tests/test_known_values.py | 30 + bindings/python/tests/test_lifecycle.py | 17 + bindings/python/tests/test_new_indicators.py | 68 +++ bindings/python/tests/test_smoke.py | 10 + .../python/tests/test_streaming_vs_batch.py | 24 + bindings/wasm/src/lib.rs | 168 ++++++ .../src/indicators/adaptive_cycle.rs | 143 +++++ .../src/indicators/center_of_gravity.rs | 193 +++++++ .../src/indicators/cybernetic_cycle.rs | 240 ++++++++ crates/wickra-core/src/indicators/decycler.rs | 213 +++++++ .../src/indicators/decycler_oscillator.rs | 179 ++++++ .../src/indicators/ehlers_stochastic.rs | 214 +++++++ .../empirical_mode_decomposition.rs | 266 +++++++++ crates/wickra-core/src/indicators/fama.rs | 161 ++++++ .../src/indicators/fisher_transform.rs | 199 +++++++ .../src/indicators/hilbert_dominant_cycle.rs | 272 +++++++++ .../src/indicators/instantaneous_trendline.rs | 213 +++++++ .../indicators/inverse_fisher_transform.rs | 164 ++++++ crates/wickra-core/src/indicators/mama.rs | 370 +++++++++++++ crates/wickra-core/src/indicators/mod.rs | 32 ++ .../src/indicators/roofing_filter.rs | 202 +++++++ .../wickra-core/src/indicators/sine_wave.rs | 218 ++++++++ .../src/indicators/super_smoother.rs | 217 ++++++++ crates/wickra-core/src/lib.rs | 58 +- crates/wickra/benches/indicators.rs | 78 ++- fuzz/fuzz_targets/indicator_update.rs | 46 +- 34 files changed, 4947 insertions(+), 46 deletions(-) create mode 100644 crates/wickra-core/src/indicators/adaptive_cycle.rs create mode 100644 crates/wickra-core/src/indicators/center_of_gravity.rs create mode 100644 crates/wickra-core/src/indicators/cybernetic_cycle.rs create mode 100644 crates/wickra-core/src/indicators/decycler.rs create mode 100644 crates/wickra-core/src/indicators/decycler_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/ehlers_stochastic.rs create mode 100644 crates/wickra-core/src/indicators/empirical_mode_decomposition.rs create mode 100644 crates/wickra-core/src/indicators/fama.rs create mode 100644 crates/wickra-core/src/indicators/fisher_transform.rs create mode 100644 crates/wickra-core/src/indicators/hilbert_dominant_cycle.rs create mode 100644 crates/wickra-core/src/indicators/instantaneous_trendline.rs create mode 100644 crates/wickra-core/src/indicators/inverse_fisher_transform.rs create mode 100644 crates/wickra-core/src/indicators/mama.rs create mode 100644 crates/wickra-core/src/indicators/roofing_filter.rs create mode 100644 crates/wickra-core/src/indicators/sine_wave.rs create mode 100644 crates/wickra-core/src/indicators/super_smoother.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d74852f5..6b4cf565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Family 10 — Ehlers / Cycle (DSP) indicators.** 16 new + streaming-first indicators implementing John Ehlers' + digital-signal-processing school of cycle analytics — a strong + differentiation feature versus TA-Lib and pandas-ta, which only + ship fragments of this catalogue: + - **MAMA / FAMA** (MESA Adaptive Moving Average + Following + Adaptive Moving Average) — phase-rate-adaptive smoothing pair + from the 2001 MESA paper, exposed both jointly via `Mama` (multi- + output) and as a scalar `Fama` wrapper. + - **Fisher Transform** and **Inverse Fisher Transform** — Gaussian + normalisation of price (Ehlers 2002) and its tanh-based bounded + counterpart for oscillators. + - **SuperSmoother**, **Roofing Filter**, **Decycler** and **Decycler + Oscillator** — 2-pole Butterworth lowpass, bandpass and + high-pass complement building blocks from *Cycle Analytics for + Traders* (2013). + - **Hilbert Dominant Cycle**, **Sine Wave** and **Adaptive Cycle** + — Hilbert-transform-based period estimation from *Rocket Science + for Traders* (2001). + - **Center of Gravity**, **Cybernetic Cycle Component**, + **Instantaneous Trendline**, **Ehlers Stochastic** and + **Empirical Mode Decomposition** — EasyLanguage classics from + Ehlers' published catalogue. + - All sixteen are exposed across Rust, Python, Node.js and WASM + bindings, fuzz-tested, benchmarked against real BTCUSDT + 1-minute data, and pass `batch == streaming` equivalence. + - Indicator count rises from 71 to **87** across **nine** families. - **DeMark family (family 11) — 12 new indicators.** TD Setup (9-bar buy/sell setup counter with parameterised lookback and target), TD Sequential (Setup + Countdown phase machine emitting setup count, diff --git a/README.md b/README.md index cfc48ce9..93f6797a 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -147 streaming-first indicators across eleven families. Every one passes the +163 streaming-first indicators across twelve families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. @@ -124,6 +124,7 @@ semantics tests. | Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop | | Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index | | Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle | +| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline | | Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag | | DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level | diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index 6d648274..c6deb1b1 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -75,6 +75,22 @@ const scalarFactories = { LaguerreRSI: () => new wickra.LaguerreRSI(0.5), ConnorsRSI: () => new wickra.ConnorsRSI(3, 2, 100), RVIVolatility: () => new wickra.RVIVolatility(10), + // Family 10 — Ehlers / Cycle + SuperSmoother: () => new wickra.SuperSmoother(10), + FisherTransform: () => new wickra.FisherTransform(10), + InverseFisherTransform: () => new wickra.InverseFisherTransform(1.0), + Decycler: () => new wickra.Decycler(20), + DecyclerOscillator: () => new wickra.DecyclerOscillator(10, 30), + RoofingFilter: () => new wickra.RoofingFilter(10, 48), + CenterOfGravity: () => new wickra.CenterOfGravity(10), + CyberneticCycle: () => new wickra.CyberneticCycle(10), + InstantaneousTrendline: () => new wickra.InstantaneousTrendline(20), + EhlersStochastic: () => new wickra.EhlersStochastic(20), + EmpiricalModeDecomposition: () => new wickra.EmpiricalModeDecomposition(20, 0.5), + HilbertDominantCycle: () => new wickra.HilbertDominantCycle(), + AdaptiveCycle: () => new wickra.AdaptiveCycle(), + SineWave: () => new wickra.SineWave(), + FAMA: () => new wickra.FAMA(0.5, 0.05), }; for (const [name, make] of Object.entries(scalarFactories)) { @@ -213,6 +229,8 @@ const multi = { TDLines: { make: () => new wickra.TDLines(4, 9), fields: ['resistance', 'support'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, TDRangeProjection: { make: () => new wickra.TDRangeProjection(), fields: ['high', 'low'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, TDRiskLevel: { make: () => new wickra.TDRiskLevel(4, 9), fields: ['buyRisk', 'sellRisk'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + // Family 10: Ehlers / Cycle (multi-output) + MAMA: { make: () => new wickra.MAMA(0.5, 0.05), fields: ['mama', 'fama'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) }, }; for (const [name, d] of Object.entries(multi)) { diff --git a/bindings/node/index.js b/bindings/node/index.js index 10bea8d7..89296238 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, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel } = 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, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -461,3 +461,19 @@ module.exports.TDRangeProjection = TDRangeProjection module.exports.TDDifferential = TDDifferential module.exports.TDOpen = TDOpen module.exports.TDRiskLevel = TDRiskLevel +module.exports.SuperSmoother = SuperSmoother +module.exports.FisherTransform = FisherTransform +module.exports.InverseFisherTransform = InverseFisherTransform +module.exports.Decycler = Decycler +module.exports.DecyclerOscillator = DecyclerOscillator +module.exports.RoofingFilter = RoofingFilter +module.exports.CenterOfGravity = CenterOfGravity +module.exports.CyberneticCycle = CyberneticCycle +module.exports.InstantaneousTrendline = InstantaneousTrendline +module.exports.EhlersStochastic = EhlersStochastic +module.exports.EmpiricalModeDecomposition = EmpiricalModeDecomposition +module.exports.HilbertDominantCycle = HilbertDominantCycle +module.exports.AdaptiveCycle = AdaptiveCycle +module.exports.SineWave = SineWave +module.exports.MAMA = MAMA +module.exports.FAMA = FAMA diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 746bd50b..faba107a 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -119,6 +119,23 @@ node_scalar_indicator!(ZScoreNode, "ZScore", wc::ZScore); node_scalar_indicator!(McGinleyDynamicNode, "McGinleyDynamic", wc::McGinleyDynamic); node_scalar_indicator!(FramaNode, "FRAMA", wc::Frama); +// Family 10 — Ehlers / Cycle: single-period scalars. +node_scalar_indicator!(SuperSmootherNode, "SuperSmoother", wc::SuperSmoother); +node_scalar_indicator!(FisherTransformNode, "FisherTransform", wc::FisherTransform); +node_scalar_indicator!(DecyclerNode, "Decycler", wc::Decycler); +node_scalar_indicator!(CenterOfGravityNode, "CenterOfGravity", wc::CenterOfGravity); +node_scalar_indicator!(CyberneticCycleNode, "CyberneticCycle", wc::CyberneticCycle); +node_scalar_indicator!( + InstantaneousTrendlineNode, + "InstantaneousTrendline", + wc::InstantaneousTrendline +); +node_scalar_indicator!( + EhlersStochasticNode, + "EhlersStochastic", + wc::EhlersStochastic +); + // RviVolatility (Relative Volatility Index, Donald Dorsey). Disambiguated // from `RVI` = Relative Vigor Index in Family 02. Takes a single `period` // parameter and additionally rejects `period == 1` (a 1-bar standard @@ -7389,3 +7406,343 @@ impl TdRiskLevelNode { self.inner.warmup_period() as u32 } } + +// ============================== Family 10 — Ehlers / Cycle ============================== + +#[napi(js_name = "InverseFisherTransform")] +pub struct InverseFisherTransformNode { + inner: wc::InverseFisherTransform, +} + +#[napi] +impl InverseFisherTransformNode { + #[napi(constructor)] + pub fn new(scale: f64) -> napi::Result { + Ok(Self { + inner: wc::InverseFisherTransform::new(scale).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "DecyclerOscillator")] +pub struct DecyclerOscillatorNode { + inner: wc::DecyclerOscillator, +} + +#[napi] +impl DecyclerOscillatorNode { + #[napi(constructor)] + pub fn new(fast: u32, slow: u32) -> napi::Result { + Ok(Self { + inner: wc::DecyclerOscillator::new(fast as usize, slow as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "RoofingFilter")] +pub struct RoofingFilterNode { + inner: wc::RoofingFilter, +} + +#[napi] +impl RoofingFilterNode { + #[napi(constructor)] + pub fn new(lp_period: u32, hp_period: u32) -> napi::Result { + Ok(Self { + inner: wc::RoofingFilter::new(lp_period as usize, hp_period as usize) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "EmpiricalModeDecomposition")] +pub struct EmpiricalModeDecompositionNode { + inner: wc::EmpiricalModeDecomposition, +} + +#[napi] +impl EmpiricalModeDecompositionNode { + #[napi(constructor)] + pub fn new(period: u32, fraction: f64) -> napi::Result { + Ok(Self { + inner: wc::EmpiricalModeDecomposition::new(period as usize, fraction) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "HilbertDominantCycle")] +pub struct HilbertDominantCycleNode { + inner: wc::HilbertDominantCycle, +} + +#[napi] +impl HilbertDominantCycleNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::HilbertDominantCycle::new(), + } + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "AdaptiveCycle")] +pub struct AdaptiveCycleNode { + inner: wc::AdaptiveCycle, +} + +#[napi] +impl AdaptiveCycleNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::AdaptiveCycle::new(), + } + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "SineWave")] +pub struct SineWaveNode { + inner: wc::SineWave, +} + +#[napi] +impl SineWaveNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::SineWave::new(), + } + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn lead(&self) -> f64 { + self.inner.lead() + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(object)] +pub struct MamaValue { + pub mama: f64, + pub fama: f64, +} + +#[napi(js_name = "MAMA")] +pub struct MamaNode { + inner: wc::Mama, +} + +#[napi] +impl MamaNode { + #[napi(constructor)] + pub fn new(fast_limit: f64, slow_limit: f64) -> napi::Result { + Ok(Self { + inner: wc::Mama::new(fast_limit, slow_limit).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value).map(|o| MamaValue { + mama: o.mama, + fama: o.fama, + }) + } + /// Returns a flat array of length `2 * n`: `[mama0, fama0, mama1, fama1, ...]`. + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + let mut out = vec![f64::NAN; prices.len() * 2]; + for (i, p) in prices.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 2] = o.mama; + out[i * 2 + 1] = o.fama; + } + } + out + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "FAMA")] +pub struct FamaNode { + inner: wc::Fama, +} + +#[napi] +impl FamaNode { + #[napi(constructor)] + pub fn new(fast_limit: f64, slow_limit: f64) -> napi::Result { + Ok(Self { + inner: wc::Fama::new(fast_limit, slow_limit).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index b90184b5..04ef96a0 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -149,6 +149,23 @@ from ._wickra import ( LinRegSlope, ZScore, LinRegAngle, + # Ehlers / Cycle + SuperSmoother, + FisherTransform, + InverseFisherTransform, + Decycler, + DecyclerOscillator, + RoofingFilter, + CenterOfGravity, + CyberneticCycle, + InstantaneousTrendline, + EhlersStochastic, + EmpiricalModeDecomposition, + HilbertDominantCycle, + AdaptiveCycle, + SineWave, + MAMA, + FAMA, # Bands & Channels MaEnvelope, AccelerationBands, @@ -310,6 +327,23 @@ __all__ = [ "LinRegSlope", "ZScore", "LinRegAngle", + # Ehlers / Cycle + "SuperSmoother", + "FisherTransform", + "InverseFisherTransform", + "Decycler", + "DecyclerOscillator", + "RoofingFilter", + "CenterOfGravity", + "CyberneticCycle", + "InstantaneousTrendline", + "EhlersStochastic", + "EmpiricalModeDecomposition", + "HilbertDominantCycle", + "AdaptiveCycle", + "SineWave", + "MAMA", + "FAMA", # Bands & Channels "MaEnvelope", "AccelerationBands", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 6e78c2f1..2f655880 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -9417,6 +9417,513 @@ impl PyTdRiskLevel { } } +// ============================== Ehlers / Cycle (Family 10) ============================== + +macro_rules! py_scalar_one_period { + ($wrapper:ident, $py_name:literal, $rust_ty:ty) => { + #[pyclass(name = $py_name, module = "wickra._wickra", skip_from_py_object)] + #[derive(Clone)] + struct $wrapper { + inner: $rust_ty, + } + + #[pymethods] + impl $wrapper { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: <$rust_ty>::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + 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!("{}(period={})", $py_name, self.inner.period()) + } + } + }; +} + +py_scalar_one_period!(PySuperSmoother, "SuperSmoother", wc::SuperSmoother); +py_scalar_one_period!(PyFisherTransform, "FisherTransform", wc::FisherTransform); +py_scalar_one_period!(PyDecycler, "Decycler", wc::Decycler); +py_scalar_one_period!(PyCenterOfGravity, "CenterOfGravity", wc::CenterOfGravity); +py_scalar_one_period!(PyCyberneticCycle, "CyberneticCycle", wc::CyberneticCycle); +py_scalar_one_period!( + PyInstantaneousTrendline, + "InstantaneousTrendline", + wc::InstantaneousTrendline +); +py_scalar_one_period!(PyEhlersStochastic, "EhlersStochastic", wc::EhlersStochastic); + +// --- InverseFisherTransform: single f64 `scale` param --- + +#[pyclass( + name = "InverseFisherTransform", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyInverseFisherTransform { + inner: wc::InverseFisherTransform, +} + +#[pymethods] +impl PyInverseFisherTransform { + #[new] + #[pyo3(signature = (scale=1.0))] + fn new(scale: f64) -> PyResult { + Ok(Self { + inner: wc::InverseFisherTransform::new(scale).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + } + #[getter] + fn scale(&self) -> f64 { + self.inner.scale() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + 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!("InverseFisherTransform(scale={})", self.inner.scale()) + } +} + +// --- DecyclerOscillator: two-period --- + +#[pyclass( + name = "DecyclerOscillator", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyDecyclerOscillator { + inner: wc::DecyclerOscillator, +} + +#[pymethods] +impl PyDecyclerOscillator { + #[new] + fn new(fast: usize, slow: usize) -> PyResult { + Ok(Self { + inner: wc::DecyclerOscillator::new(fast, slow).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + } + #[getter] + fn periods(&self) -> (usize, usize) { + self.inner.periods() + } + 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 { + let (f, s) = self.inner.periods(); + format!("DecyclerOscillator(fast={f}, slow={s})") + } +} + +// --- RoofingFilter: two-period (lp, hp) --- + +#[pyclass(name = "RoofingFilter", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyRoofingFilter { + inner: wc::RoofingFilter, +} + +#[pymethods] +impl PyRoofingFilter { + #[new] + #[pyo3(signature = (lp_period=10, hp_period=48))] + fn new(lp_period: usize, hp_period: usize) -> PyResult { + Ok(Self { + inner: wc::RoofingFilter::new(lp_period, hp_period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + } + #[getter] + fn periods(&self) -> (usize, usize) { + self.inner.periods() + } + 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 { + let (lp, hp) = self.inner.periods(); + format!("RoofingFilter(lp_period={lp}, hp_period={hp})") + } +} + +// --- EmpiricalModeDecomposition: period + fraction --- + +#[pyclass( + name = "EmpiricalModeDecomposition", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyEmd { + inner: wc::EmpiricalModeDecomposition, +} + +#[pymethods] +impl PyEmd { + #[new] + #[pyo3(signature = (period=20, fraction=0.5))] + fn new(period: usize, fraction: f64) -> PyResult { + Ok(Self { + inner: wc::EmpiricalModeDecomposition::new(period, fraction).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn fraction(&self) -> f64 { + self.inner.fraction() + } + 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!( + "EmpiricalModeDecomposition(period={}, fraction={})", + self.inner.period(), + self.inner.fraction() + ) + } +} + +// --- HilbertDominantCycle / SineWave / AdaptiveCycle: parameterless --- + +macro_rules! py_no_params_scalar { + ($wrapper:ident, $py_name:literal, $rust_ty:ty) => { + #[pyclass(name = $py_name, module = "wickra._wickra", skip_from_py_object)] + #[derive(Clone)] + struct $wrapper { + inner: $rust_ty, + } + + #[pymethods] + impl $wrapper { + #[new] + fn new() -> Self { + Self { + inner: <$rust_ty>::new(), + } + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + 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!("{}()", $py_name) + } + } + }; +} + +py_no_params_scalar!( + PyHilbertDominantCycle, + "HilbertDominantCycle", + wc::HilbertDominantCycle +); +py_no_params_scalar!(PyAdaptiveCycle, "AdaptiveCycle", wc::AdaptiveCycle); + +// SineWave needs a `lead` accessor in addition to scalar value, but otherwise +// matches the parameterless surface. +#[pyclass(name = "SineWave", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PySineWave { + inner: wc::SineWave, +} + +#[pymethods] +impl PySineWave { + #[new] + fn new() -> Self { + Self { + inner: wc::SineWave::new(), + } + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + #[getter] + fn lead(&self) -> f64 { + self.inner.lead() + } + 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 { + "SineWave()".to_string() + } +} + +// --- MAMA: multi-output (mama, fama), shape (n, 2) --- + +#[pyclass(name = "MAMA", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyMama { + inner: wc::Mama, +} + +#[pymethods] +impl PyMama { + #[new] + #[pyo3(signature = (fast_limit=0.5, slow_limit=0.05))] + fn new(fast_limit: f64, slow_limit: f64) -> PyResult { + Ok(Self { + inner: wc::Mama::new(fast_limit, slow_limit).map_err(map_err)?, + }) + } + /// Returns `(mama, fama)` or `None` during warmup. + fn update(&mut self, value: f64) -> Option<(f64, f64)> { + self.inner.update(value).map(|o| (o.mama, o.fama)) + } + /// Batch returns shape `(n, 2)` columns `[mama, fama]`. Warmup rows NaN. + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let n = slice.len(); + let mut out = vec![f64::NAN; n * 2]; + for (i, p) in slice.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 2] = o.mama; + out[i * 2 + 1] = o.fama; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .into_pyarray(py)) + } + #[getter] + fn limits(&self) -> (f64, f64) { + self.inner.limits() + } + 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 { + let (f, s) = self.inner.limits(); + format!("MAMA(fast_limit={f}, slow_limit={s})") + } +} + +// --- FAMA: scalar wrapper exposing only the fama line --- + +#[pyclass(name = "FAMA", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyFama { + inner: wc::Fama, +} + +#[pymethods] +impl PyFama { + #[new] + #[pyo3(signature = (fast_limit=0.5, slow_limit=0.05))] + fn new(fast_limit: f64, slow_limit: f64) -> PyResult { + Ok(Self { + inner: wc::Fama::new(fast_limit, slow_limit).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + } + #[getter] + fn limits(&self) -> (f64, f64) { + self.inner.limits() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + 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 { + let (f, s) = self.inner.limits(); + format!("FAMA(fast_limit={f}, slow_limit={s})") + } +} + // ============================== Module ============================== #[pymodule] @@ -9572,5 +10079,22 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + // Family 10 — Ehlers / Cycle + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/python/tests/test_input_validation.py b/bindings/python/tests/test_input_validation.py index 0615ccb5..06bffe90 100644 --- a/bindings/python/tests/test_input_validation.py +++ b/bindings/python/tests/test_input_validation.py @@ -39,3 +39,20 @@ def test_roc_and_trix_have_default_periods(): # ROC/TRIX gained constructor defaults matching the TA-Lib convention. assert ta.ROC().period == 10 assert ta.TRIX() is not None + + +def test_family_10_ehlers_rejects_invalid_parameters(): + with pytest.raises(ValueError): + ta.SuperSmoother(0) + with pytest.raises(ValueError): + ta.FisherTransform(0) + with pytest.raises(ValueError): + ta.InverseFisherTransform(0.0) + with pytest.raises(ValueError): + ta.DecyclerOscillator(30, 10) + with pytest.raises(ValueError): + ta.RoofingFilter(48, 10) + with pytest.raises(ValueError): + ta.MAMA(0.05, 0.5) + with pytest.raises(ValueError): + ta.EmpiricalModeDecomposition(20, 0.0) diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index eb19b20a..824eb58f 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -332,6 +332,36 @@ def test_obv_cumulative_known_sequence(): np.testing.assert_allclose(out, [0.0, 20.0, -10.0, -10.0, 0.0]) +# --- Family 10 — Ehlers / Cycle reference values --- + + +def test_inverse_fisher_saturates_for_large_input(): + # tanh(10) ~ 0.99999996; very close to +1 without exceeding. + v = ta.InverseFisherTransform(1.0).batch(np.array([10.0]))[0] + assert v < 1.0 + assert v > 0.999 + + +def test_super_smoother_constant_input_is_constant(): + out = ta.SuperSmoother(20).batch(np.full(200, 50.0)) + # Steady-state gain is 1, so a flat input stays flat. + np.testing.assert_allclose(out[-50:], 50.0, atol=1e-9) + + +def test_decycler_oscillator_flat_series_is_zero(): + out = ta.DecyclerOscillator(10, 30).batch(np.full(80, 42.0)) + ready = out[~np.isnan(out)] + np.testing.assert_allclose(ready, 0.0, atol=1e-9) + + +def test_mama_constant_series_both_lines_converge_to_price(): + out = ta.MAMA().batch(np.full(200, 100.0)) + last = out[-1] + # MAMA and FAMA both track price closely on a flat series. + assert abs(last[0] - 100.0) < 1.0 + assert abs(last[1] - 100.0) < 1.0 + + # --- DeMark family --------------------------------------------------------- diff --git a/bindings/python/tests/test_lifecycle.py b/bindings/python/tests/test_lifecycle.py index 27a5e523..5e4c767c 100644 --- a/bindings/python/tests/test_lifecycle.py +++ b/bindings/python/tests/test_lifecycle.py @@ -86,3 +86,20 @@ def test_candle_tuple_input_supported(): atr.update((10.0, 11.0, 9.0, 10.5, 1.0, 0)) v = atr.update((10.5, 12.0, 10.0, 11.0, 1.0, 1)) assert v is not None + + +def test_ehlers_indicators_lifecycle(): + # Spot-check a few Family-10 entries beyond what test_new_indicators covers. + series = np.linspace(1.0, 200.0, 200) + np.sin(np.arange(200) * 0.3) * 5.0 + for ind in [ + ta.SuperSmoother(10), + ta.FisherTransform(10), + ta.MAMA(), + ta.HilbertDominantCycle(), + ta.SineWave(), + ]: + assert not ind.is_ready() + ind.batch(series) + assert ind.is_ready() + ind.reset() + assert not ind.is_ready() diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index 6210c185..324aa351 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -79,6 +79,22 @@ SCALAR = [ (ta.LaguerreRSI, (0.5,)), (ta.ConnorsRSI, (3, 2, 100)), (ta.RVIVolatility, (10,)), + # Family 10 — Ehlers / Cycle scalar indicators + (ta.SuperSmoother, (10,)), + (ta.FisherTransform, (10,)), + (ta.InverseFisherTransform, (1.0,)), + (ta.Decycler, (20,)), + (ta.DecyclerOscillator, (10, 30)), + (ta.RoofingFilter, (10, 48)), + (ta.CenterOfGravity, (10,)), + (ta.CyberneticCycle, (10,)), + (ta.InstantaneousTrendline, (20,)), + (ta.EhlersStochastic, (20,)), + (ta.EmpiricalModeDecomposition, (20, 0.5)), + (ta.HilbertDominantCycle, ()), + (ta.AdaptiveCycle, ()), + (ta.SineWave, ()), + (ta.FAMA, (0.5, 0.05)), ] @@ -434,6 +450,10 @@ MULTI_SCALAR_INPUT = { lambda: ta.KST(10, 15, 20, 30, 10, 10, 10, 15, 9), lambda ind, c: ind.batch(c), ), + "MAMA": ( + lambda: ta.MAMA(0.5, 0.05), + lambda ind, c: ind.batch(c), + ), } @@ -888,6 +908,54 @@ def test_z_score_reference(): assert out[1] == pytest.approx(1.0) +# --- Family 10 — Ehlers / Cycle --- + + +def test_mama_batch_shape_and_streaming_equivalence(sine_prices): + batch = ta.MAMA().batch(sine_prices) + assert batch.shape == (sine_prices.size, 2) + + streamer = ta.MAMA() + rows = [] + for p in sine_prices: + v = streamer.update(float(p)) + rows.append([math.nan, math.nan] if v is None else list(v)) + streamed = np.array(rows, dtype=np.float64) + assert _eq_nan(batch, streamed) + + +def test_inverse_fisher_transform_zero_input_yields_zero(): + out = ta.InverseFisherTransform(1.0).batch(np.array([0.0, 0.0, 0.0])) + np.testing.assert_allclose(out, [0.0, 0.0, 0.0], atol=1e-12) + + +def test_fisher_transform_flat_series_is_zero(): + # Zero range -> the normaliser yields 0, and tanh(0) chain stays at 0. + out = ta.FisherTransform(5).batch(np.full(20, 42.0)) + ready = out[~np.isnan(out)] + assert np.all(np.abs(ready) < 1e-6) + + +def test_decycler_flat_series_passes_through(): + # High-pass of a flat input is zero, so the decycler equals the input. + out = ta.Decycler(20).batch(np.full(30, 100.0)) + ready = out[~np.isnan(out)] + np.testing.assert_allclose(ready, 100.0, atol=1e-9) + + +def test_center_of_gravity_flat_series_is_zero(): + out = ta.CenterOfGravity(5).batch(np.full(20, 7.0)) + ready = out[~np.isnan(out)] + np.testing.assert_allclose(ready, 0.0, atol=1e-12) + + +def test_super_smoother_first_two_outputs_equal_inputs(): + out = ta.SuperSmoother(10).batch(np.array([100.0, 101.0, 102.0])) + # The 2-pole filter is seeded with raw values for the first two bars. + assert out[0] == pytest.approx(100.0) + assert out[1] == pytest.approx(101.0) + + def test_td_setup_pure_uptrend_reaches_minus_9(): # Every close is strictly greater than four bars ago -> sell-setup -9. h = np.arange(2.0, 22.0) diff --git a/bindings/python/tests/test_smoke.py b/bindings/python/tests/test_smoke.py index 92b64d4b..6e5f4165 100644 --- a/bindings/python/tests/test_smoke.py +++ b/bindings/python/tests/test_smoke.py @@ -55,3 +55,13 @@ def test_obv_batch_shape(ohlc_series): volume = np.ones_like(close) out = ta.OBV().batch(close, volume) assert out.shape == close.shape + + +def test_ehlers_super_smoother_batch_shape(sine_prices): + out = ta.SuperSmoother(10).batch(sine_prices) + assert out.shape == sine_prices.shape + + +def test_mama_batch_shape(sine_prices): + out = ta.MAMA().batch(sine_prices) + assert out.shape == (sine_prices.size, 2) diff --git a/bindings/python/tests/test_streaming_vs_batch.py b/bindings/python/tests/test_streaming_vs_batch.py index dbee1184..96e3a0a5 100644 --- a/bindings/python/tests/test_streaming_vs_batch.py +++ b/bindings/python/tests/test_streaming_vs_batch.py @@ -117,6 +117,30 @@ def test_obv_streaming_matches_batch(ohlc_series): assert _equal_with_nan(batch, streamed) +def test_mama_streaming_matches_batch(sine_prices): + batch = ta.MAMA().batch(sine_prices) + streamer = ta.MAMA() + rows = [] + for p in sine_prices: + v = streamer.update(float(p)) + if v is None: + rows.append([math.nan, math.nan]) + else: + rows.append(list(v)) + streamed = np.array(rows, dtype=np.float64) + assert _equal_with_nan(batch, streamed) + + +def test_super_smoother_streaming_matches_batch(sine_prices): + batch = ta.SuperSmoother(10).batch(sine_prices) + streamer = ta.SuperSmoother(10) + streamed = np.array( + [math.nan if (v := streamer.update(float(p))) is None else float(v) for p in sine_prices], + dtype=np.float64, + ) + assert _equal_with_nan(batch, streamed) + + def test_rolling_vwap_streaming_matches_batch(ohlc_series): # RollingVWAP(20) on the shared OHLC series. Provides finite-memory VWAP # parity coverage now that the indicator is exposed across all bindings. diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 215f3304..d42a2464 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -448,6 +448,20 @@ impl WasmParkinsonVolatility { } } +// Family 10 — Ehlers / Cycle scalars +wasm_scalar_indicator!(WasmSuperSmoother, "SuperSmoother", wc::SuperSmoother, period: usize); +wasm_scalar_indicator!(WasmFisherTransform, "FisherTransform", wc::FisherTransform, period: usize); +wasm_scalar_indicator!(WasmInverseFisherTransform, "InverseFisherTransform", wc::InverseFisherTransform, scale: f64); +wasm_scalar_indicator!(WasmDecycler, "Decycler", wc::Decycler, period: usize); +wasm_scalar_indicator!(WasmDecyclerOscillator, "DecyclerOscillator", wc::DecyclerOscillator, fast: usize, slow: usize); +wasm_scalar_indicator!(WasmRoofingFilter, "RoofingFilter", wc::RoofingFilter, lp_period: usize, hp_period: usize); +wasm_scalar_indicator!(WasmCenterOfGravity, "CenterOfGravity", wc::CenterOfGravity, period: usize); +wasm_scalar_indicator!(WasmCyberneticCycle, "CyberneticCycle", wc::CyberneticCycle, period: usize); +wasm_scalar_indicator!(WasmInstantaneousTrendline, "InstantaneousTrendline", wc::InstantaneousTrendline, period: usize); +wasm_scalar_indicator!(WasmEhlersStochastic, "EhlersStochastic", wc::EhlersStochastic, period: usize); +wasm_scalar_indicator!(WasmEmpiricalModeDecomposition, "EmpiricalModeDecomposition", wc::EmpiricalModeDecomposition, period: usize, fraction: f64); +wasm_scalar_indicator!(WasmFama, "FAMA", wc::Fama, fast_limit: f64, slow_limit: f64); + // ---------- KAMA (three params) ---------- #[wasm_bindgen(js_name = KAMA)] @@ -3486,6 +3500,159 @@ impl WasmAroon { } } +// ============================== Family 10: parameterless / multi-output ============================== + +#[wasm_bindgen(js_name = HilbertDominantCycle)] +pub struct WasmHilbertDominantCycle { + inner: wc::HilbertDominantCycle, +} + +#[wasm_bindgen(js_class = HilbertDominantCycle)] +impl WasmHilbertDominantCycle { + #[wasm_bindgen(constructor)] + #[allow(clippy::new_without_default)] + pub fn new() -> WasmHilbertDominantCycle { + Self { + inner: wc::HilbertDominantCycle::new(), + } + } + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + Float64Array::from(flatten(self.inner.batch(prices)).as_slice()) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = AdaptiveCycle)] +pub struct WasmAdaptiveCycle { + inner: wc::AdaptiveCycle, +} + +#[wasm_bindgen(js_class = AdaptiveCycle)] +impl WasmAdaptiveCycle { + #[wasm_bindgen(constructor)] + #[allow(clippy::new_without_default)] + pub fn new() -> WasmAdaptiveCycle { + Self { + inner: wc::AdaptiveCycle::new(), + } + } + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + Float64Array::from(flatten(self.inner.batch(prices)).as_slice()) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = SineWave)] +pub struct WasmSineWave { + inner: wc::SineWave, +} + +#[wasm_bindgen(js_class = SineWave)] +impl WasmSineWave { + #[wasm_bindgen(constructor)] + #[allow(clippy::new_without_default)] + pub fn new() -> WasmSineWave { + Self { + inner: wc::SineWave::new(), + } + } + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + Float64Array::from(flatten(self.inner.batch(prices)).as_slice()) + } + pub fn lead(&self) -> f64 { + self.inner.lead() + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = MAMA)] +pub struct WasmMama { + inner: wc::Mama, +} + +#[wasm_bindgen(js_class = MAMA)] +impl WasmMama { + #[wasm_bindgen(constructor)] + pub fn new(fast_limit: f64, slow_limit: f64) -> Result { + Ok(Self { + inner: wc::Mama::new(fast_limit, slow_limit).map_err(map_err)?, + }) + } + pub fn update(&mut self, value: f64) -> JsValue { + match self.inner.update(value) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"mama".into(), &o.mama.into()).ok(); + Reflect::set(&obj, &"fama".into(), &o.fama.into()).ok(); + obj.into() + } + None => JsValue::NULL, + } + } + /// Returns a flat `Float64Array` of length `2 * n`: `[mama0, fama0, mama1, fama1, ...]`. + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + let n = prices.len(); + let mut out = vec![f64::NAN; n * 2]; + for (i, p) in prices.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 2] = o.mama; + out[i * 2 + 1] = o.fama; + } + } + Float64Array::from(out.as_slice()) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + // ============================== Family 05: Bands & Channels ============================== // Every indicator below is multi-output (2-5 bands), so they bypass the @@ -5335,6 +5502,7 @@ impl WasmTdRiskLevel { self.inner.warmup_period() } } + #[cfg(test)] mod tests { use super::*; diff --git a/crates/wickra-core/src/indicators/adaptive_cycle.rs b/crates/wickra-core/src/indicators/adaptive_cycle.rs new file mode 100644 index 00000000..f41689c4 --- /dev/null +++ b/crates/wickra-core/src/indicators/adaptive_cycle.rs @@ -0,0 +1,143 @@ +//! Ehlers Adaptive Cycle period estimator (for adaptive oscillators). + +use crate::indicators::hilbert_dominant_cycle::HilbertDominantCycle; +use crate::traits::Indicator; + +/// Ehlers' Adaptive Cycle Indicator. +/// +/// Returns half the current dominant cycle period — the "best" lookback for +/// downstream oscillators like an adaptive RSI or adaptive Stochastic, per +/// Ehlers' *Cycle Analytics for Traders* (2013, ch. 11). Halving accounts for +/// the fact that an oscillator over a half-cycle captures the full peak-to- +/// trough swing without aliasing. +/// +/// The output is rounded to an integer-valued `f64` and clamped to `[3, 25]`, +/// matching the typical operating range of period-adaptive oscillators. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, AdaptiveCycle}; +/// +/// let mut ac = AdaptiveCycle::new(); +/// let mut last = None; +/// for i in 0..200 { +/// last = ac.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AdaptiveCycle { + cycle: HilbertDominantCycle, + last_value: Option, +} + +impl AdaptiveCycle { + /// Construct a new adaptive cycle estimator. + pub fn new() -> Self { + Self::default() + } + + /// Current adaptive period if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for AdaptiveCycle { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let period = self.cycle.update(input)?; + let half = (period * 0.5).round().clamp(3.0, 25.0); + self.last_value = Some(half); + Some(half) + } + + fn reset(&mut self) { + self.cycle.reset(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.cycle.warmup_period() + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "AdaptiveCycle" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn accessors_and_metadata() { + let mut ac = AdaptiveCycle::new(); + assert_eq!(ac.warmup_period(), 50); + assert_eq!(ac.name(), "AdaptiveCycle"); + assert!(!ac.is_ready()); + assert!(ac.value().is_none()); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + ac.batch(&prices); + assert!(ac.is_ready()); + assert!(ac.value().is_some()); + } + + #[test] + fn output_within_clamp_band() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0) + .collect(); + let mut ac = AdaptiveCycle::new(); + for v in ac.batch(&prices).into_iter().flatten() { + assert!((3.0..=25.0).contains(&v), "period {v} out of band"); + assert_eq!(v, v.round(), "expected integer-valued output"); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut a = AdaptiveCycle::new(); + let mut b = AdaptiveCycle::new(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut ac = AdaptiveCycle::new(); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + ac.batch(&prices); + let before = ac.value(); + assert!(before.is_some()); + assert_eq!(ac.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut ac = AdaptiveCycle::new(); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + ac.batch(&prices); + assert!(ac.is_ready()); + ac.reset(); + assert!(!ac.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/center_of_gravity.rs b/crates/wickra-core/src/indicators/center_of_gravity.rs new file mode 100644 index 00000000..5340e621 --- /dev/null +++ b/crates/wickra-core/src/indicators/center_of_gravity.rs @@ -0,0 +1,193 @@ +//! Ehlers Center of Gravity Oscillator. +#![allow(clippy::manual_midpoint)] + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' Center of Gravity (CG) oscillator. +/// +/// Treats the most recent `period` prices as masses and reports the +/// weighted "center" of that mass distribution, negated so positive readings +/// correspond to recent strength: +/// +/// ```text +/// num = sum_{k=0..period-1} (1 + k) * price[t - k] +/// den = sum_{k=0..period-1} price[t - k] +/// cg = - num / den + (period + 1) / 2 +/// ``` +/// +/// The constant offset centres the oscillator around zero. From Ehlers, +/// *Cybernetic Analysis for Stocks and Futures* (2004, ch. 7). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, CenterOfGravity}; +/// +/// let mut cg = CenterOfGravity::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// last = cg.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct CenterOfGravity { + period: usize, + window: VecDeque, + last_value: Option, +} + +impl CenterOfGravity { + /// Construct with the rolling window length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for CenterOfGravity { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + // Most recent has weight 1; oldest has weight `period`. + let mut num = 0.0; + let mut den = 0.0; + for (k, p) in self.window.iter().rev().enumerate() { + let w = 1.0 + k as f64; + num += w * p; + den += p; + } + let v = if den.abs() > f64::EPSILON { + -num / den + (self.period as f64 + 1.0) / 2.0 + } else { + 0.0 + }; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.window.clear(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "CenterOfGravity" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(CenterOfGravity::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut cg = CenterOfGravity::new(10).unwrap(); + assert_eq!(cg.period(), 10); + assert_eq!(cg.warmup_period(), 10); + assert_eq!(cg.name(), "CenterOfGravity"); + assert!(!cg.is_ready()); + for i in 1..=10 { + cg.update(f64::from(i)); + } + assert!(cg.is_ready()); + assert!(cg.value().is_some()); + } + + #[test] + fn constant_series_yields_zero() { + // num = sum k * p, den = period * p, ratio = (period + 1) / 2, + // so cg = - (period+1)/2 + (period+1)/2 = 0. + let mut cg = CenterOfGravity::new(5).unwrap(); + let out = cg.batch(&[7.0_f64; 30]); + for x in out.iter().skip(5).flatten() { + assert_relative_eq!(*x, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=50).map(f64::from).collect(); + let mut a = CenterOfGravity::new(10).unwrap(); + let mut b = CenterOfGravity::new(10).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut cg = CenterOfGravity::new(5).unwrap(); + cg.batch(&(1..=10).map(f64::from).collect::>()); + let before = cg.value(); + assert!(before.is_some()); + assert_eq!(cg.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut cg = CenterOfGravity::new(5).unwrap(); + cg.batch(&(1..=10).map(f64::from).collect::>()); + assert!(cg.is_ready()); + cg.reset(); + assert!(!cg.is_ready()); + } + + #[test] + fn warmup_returns_none_until_seed() { + let mut cg = CenterOfGravity::new(4).unwrap(); + assert_eq!(cg.update(1.0), None); + assert_eq!(cg.update(2.0), None); + assert_eq!(cg.update(3.0), None); + assert!(cg.update(4.0).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/cybernetic_cycle.rs b/crates/wickra-core/src/indicators/cybernetic_cycle.rs new file mode 100644 index 00000000..955d5037 --- /dev/null +++ b/crates/wickra-core/src/indicators/cybernetic_cycle.rs @@ -0,0 +1,240 @@ +//! Ehlers Cybernetic Cycle Component. +#![allow(clippy::doc_markdown)] + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' Cybernetic Cycle Component (CCC). +/// +/// Classic EasyLanguage construct from *Cybernetic Analysis for Stocks and +/// Futures* (Ehlers 2004, ch. 4): +/// +/// ```text +/// smooth[t] = (x[t] + 2*x[t-1] + 2*x[t-2] + x[t-3]) / 6 +/// cycle[t] = (1 - alpha/2)^2 * (smooth[t] - 2*smooth[t-1] + smooth[t-2]) +/// + 2 * (1 - alpha) * cycle[t-1] +/// - (1 - alpha)^2 * cycle[t-2] +/// ``` +/// +/// The result is a near-zero-mean oscillator that tracks the dominant cycle +/// component while filtering trend. `alpha` is a smoothing fraction in +/// `(0, 1]`; Ehlers recommends `2 / (period + 1)` for a given critical period. +/// +/// The first six outputs follow Ehlers' "use the input directly" initial +/// condition so downstream consumers stay reactive. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, CyberneticCycle}; +/// +/// let mut cc = CyberneticCycle::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// last = cc.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct CyberneticCycle { + period: usize, + alpha: f64, + in_buf: [Option; 4], + smooth_buf: [Option; 3], + cycle_buf: [Option; 3], + count: usize, + last_value: Option, +} + +impl CyberneticCycle { + /// Construct with the dominant-cycle period (alpha = 2 / (period + 1)). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let alpha = 2.0 / (period as f64 + 1.0); + Ok(Self { + period, + alpha, + in_buf: [None; 4], + smooth_buf: [None; 3], + cycle_buf: [None; 3], + count: 0, + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Smoothing alpha. + pub const fn alpha(&self) -> f64 { + self.alpha + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } + + /// Shift in `x` at position 0 of a 3-slot buffer. + fn push3(buf: &mut [Option; 3], x: f64) { + buf[2] = buf[1]; + buf[1] = buf[0]; + buf[0] = Some(x); + } + fn push4(buf: &mut [Option; 4], x: f64) { + buf[3] = buf[2]; + buf[2] = buf[1]; + buf[1] = buf[0]; + buf[0] = Some(x); + } +} + +impl Indicator for CyberneticCycle { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + self.count += 1; + Self::push4(&mut self.in_buf, input); + + // Smooth needs four prior inputs (positions 0..=3). + let smooth = if let (Some(a), Some(b), Some(c), Some(d)) = ( + self.in_buf[0], + self.in_buf[1], + self.in_buf[2], + self.in_buf[3], + ) { + (a + 2.0 * b + 2.0 * c + d) / 6.0 + } else { + // Initial condition: use the raw input. + input + }; + Self::push3(&mut self.smooth_buf, smooth); + + // Cycle needs two prior smooths and two prior cycles. + let one_minus_half_alpha = 1.0 - self.alpha / 2.0; + let one_minus_alpha = 1.0 - self.alpha; + let drv = one_minus_half_alpha * one_minus_half_alpha; + + let cycle = if let (Some(s0), Some(s1), Some(s2), Some(c1), Some(c2)) = ( + self.smooth_buf[0], + self.smooth_buf[1], + self.smooth_buf[2], + self.cycle_buf[0], + self.cycle_buf[1], + ) { + drv * (s0 - 2.0 * s1 + s2) + 2.0 * one_minus_alpha * c1 + - one_minus_alpha * one_minus_alpha * c2 + } else if self.count < 7 { + // Ehlers initial condition: cycle starts as the second-difference + // of the raw input series, scaled by 0.5 (matches the EasyLanguage + // implementation's first-bar fallback). + let (x0, x1, x2) = ( + self.in_buf[0].unwrap_or(input), + self.in_buf[1].unwrap_or(input), + self.in_buf[2].unwrap_or(input), + ); + (x0 - 2.0 * x1 + x2) / 4.0 + } else { + 0.0 + }; + + Self::push3(&mut self.cycle_buf, cycle); + self.last_value = Some(cycle); + Some(cycle) + } + + fn reset(&mut self) { + self.in_buf = [None; 4]; + self.smooth_buf = [None; 3]; + self.cycle_buf = [None; 3]; + self.count = 0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "CyberneticCycle" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(CyberneticCycle::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut cc = CyberneticCycle::new(10).unwrap(); + assert_eq!(cc.period(), 10); + assert_relative_eq!(cc.alpha(), 2.0 / 11.0, epsilon = 1e-15); + assert_eq!(cc.warmup_period(), 1); + assert_eq!(cc.name(), "CyberneticCycle"); + assert!(!cc.is_ready()); + cc.update(100.0); + assert!(cc.is_ready()); + } + + #[test] + fn constant_series_converges_to_zero() { + let mut cc = CyberneticCycle::new(10).unwrap(); + let out = cc.batch(&[50.0_f64; 200]); + for x in out.iter().skip(50).flatten() { + assert_relative_eq!(*x, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 5.0) + .collect(); + let mut a = CyberneticCycle::new(15).unwrap(); + let mut b = CyberneticCycle::new(15).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut cc = CyberneticCycle::new(10).unwrap(); + cc.batch(&(1..=30).map(f64::from).collect::>()); + let before = cc.value(); + assert!(before.is_some()); + assert_eq!(cc.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut cc = CyberneticCycle::new(10).unwrap(); + cc.batch(&(1..=30).map(f64::from).collect::>()); + assert!(cc.is_ready()); + cc.reset(); + assert!(!cc.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/decycler.rs b/crates/wickra-core/src/indicators/decycler.rs new file mode 100644 index 00000000..85fd5114 --- /dev/null +++ b/crates/wickra-core/src/indicators/decycler.rs @@ -0,0 +1,213 @@ +//! Ehlers Decycler (single-pole high-pass complement). + +use std::f64::consts::PI; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' Decycler: price minus the dominant cycle component. +/// +/// Implemented as `decycler = input - HP(input)`, where `HP` is a 2-pole +/// high-pass filter with critical period `period`. Subtracting the high-pass +/// from the raw price leaves the slow component — equivalent to a smoothed +/// trend line with no group delay at low frequencies. From *Cycle Analytics +/// for Traders* (Ehlers 2013, ch. 4). +/// +/// The high-pass uses the standard 2-pole formulation: +/// +/// ```text +/// alpha = (cos(.707*2*pi/period) + sin(.707*2*pi/period) - 1) / cos(.707*2*pi/period) +/// HP[t] = (1 - alpha/2)^2 * (x[t] - 2*x[t-1] + x[t-2]) +/// + 2*(1 - alpha) * HP[t-1] +/// - (1 - alpha)^2 * HP[t-2] +/// ``` +/// +/// The first two outputs simply equal the input (warmup buffering), which is +/// the conventional Ehlers initialisation and keeps downstream consumers +/// reactive while the recursion fills. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Decycler}; +/// +/// let mut dc = Decycler::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..50 { +/// last = dc.update(100.0 + f64::from(i) * 0.5); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Decycler { + period: usize, + alpha: f64, + prev_in_1: Option, + prev_in_2: Option, + prev_hp_1: f64, + prev_hp_2: f64, + last_value: Option, +} + +impl Decycler { + /// Construct a Decycler with the given critical period for the high-pass filter. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let arg = 0.707 * 2.0 * PI / period as f64; + let c = arg.cos(); + let alpha = (c + arg.sin() - 1.0) / c; + Ok(Self { + period, + alpha, + prev_in_1: None, + prev_in_2: None, + prev_hp_1: 0.0, + prev_hp_2: 0.0, + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// High-pass `alpha` coefficient derived from the period. + pub const fn alpha(&self) -> f64 { + self.alpha + } + + /// Current decycler value if available. + pub const fn value(&self) -> Option { + self.last_value + } + + /// Compute and store the high-pass output for the latest input. + fn step_hp(&mut self, input: f64) -> f64 { + let (Some(x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) else { + self.prev_hp_2 = self.prev_hp_1; + self.prev_hp_1 = 0.0; + return 0.0; + }; + let one_minus_half_alpha = 1.0 - self.alpha / 2.0; + let one_minus_alpha = 1.0 - self.alpha; + let drv = one_minus_half_alpha * one_minus_half_alpha; + let term1 = drv * (input - 2.0 * x1 + x2); + let term2 = 2.0 * one_minus_alpha * self.prev_hp_1; + let term3 = one_minus_alpha * one_minus_alpha * self.prev_hp_2; + let hp = term1 + term2 - term3; + self.prev_hp_2 = self.prev_hp_1; + self.prev_hp_1 = hp; + hp + } +} + +impl Indicator for Decycler { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + let hp = self.step_hp(input); + let v = input - hp; + self.prev_in_2 = self.prev_in_1; + self.prev_in_1 = Some(input); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev_in_1 = None; + self.prev_in_2 = None; + self.prev_hp_1 = 0.0; + self.prev_hp_2 = 0.0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "Decycler" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Decycler::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut dc = Decycler::new(20).unwrap(); + assert_eq!(dc.period(), 20); + assert_eq!(dc.warmup_period(), 1); + assert_eq!(dc.name(), "Decycler"); + assert!(dc.alpha() > 0.0 && dc.alpha() < 1.0); + assert!(!dc.is_ready()); + dc.update(100.0); + assert!(dc.is_ready()); + assert!(dc.value().is_some()); + } + + #[test] + fn constant_series_passes_through() { + // For a flat input, the high-pass output is zero, so the decycler + // equals the input. + let mut dc = Decycler::new(20).unwrap(); + let out = dc.batch(&[42.0_f64; 80]); + for x in out.iter().flatten() { + assert_relative_eq!(*x, 42.0, epsilon = 1e-9); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..100) + .map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 5.0) + .collect(); + let mut a = Decycler::new(20).unwrap(); + let mut b = Decycler::new(20).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut dc = Decycler::new(20).unwrap(); + dc.batch(&(1..=30).map(f64::from).collect::>()); + let before = dc.value(); + assert!(before.is_some()); + assert_eq!(dc.update(f64::NAN), before); + assert_eq!(dc.update(f64::INFINITY), before); + } + + #[test] + fn reset_clears_state() { + let mut dc = Decycler::new(20).unwrap(); + dc.batch(&(1..=40).map(f64::from).collect::>()); + assert!(dc.is_ready()); + dc.reset(); + assert!(!dc.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/decycler_oscillator.rs b/crates/wickra-core/src/indicators/decycler_oscillator.rs new file mode 100644 index 00000000..3462eda3 --- /dev/null +++ b/crates/wickra-core/src/indicators/decycler_oscillator.rs @@ -0,0 +1,179 @@ +//! Ehlers Decycler Oscillator (difference of two decyclers). + +use crate::error::{Error, Result}; +use crate::indicators::decycler::Decycler; +use crate::traits::Indicator; + +/// Difference between a fast and a slow [`Decycler`], producing a smoothed +/// oscillator that crosses zero at trend changes. +/// +/// Defined as `fast_decycler - slow_decycler` with `fast_period < slow_period`. +/// The construct removes the trend component that both decyclers share, leaving +/// the medium-frequency cycle band — analogous in spirit to MACD but with +/// Ehlers' zero-lag high-pass filters instead of EMAs. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, DecyclerOscillator}; +/// +/// let mut dco = DecyclerOscillator::new(10, 30).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// last = dco.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct DecyclerOscillator { + fast: Decycler, + slow: Decycler, + last_value: Option, +} + +impl DecyclerOscillator { + /// Construct with the fast and slow periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is zero, and + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize) -> Result { + if fast == 0 || slow == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "fast period must be strictly less than slow period", + }); + } + Ok(Self { + fast: Decycler::new(fast)?, + slow: Decycler::new(slow)?, + last_value: None, + }) + } + + /// Configured `(fast, slow)` periods. + pub fn periods(&self) -> (usize, usize) { + (self.fast.period(), self.slow.period()) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for DecyclerOscillator { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + let (Some(f), Some(s)) = (self.fast.update(input), self.slow.update(input)) else { + return None; + }; + let v = f - s; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.fast.warmup_period().max(self.slow.warmup_period()) + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "DecyclerOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_invalid_periods() { + assert!(matches!( + DecyclerOscillator::new(0, 20), + Err(Error::PeriodZero) + )); + assert!(matches!( + DecyclerOscillator::new(10, 0), + Err(Error::PeriodZero) + )); + assert!(matches!( + DecyclerOscillator::new(20, 10), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + DecyclerOscillator::new(10, 10), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let mut dco = DecyclerOscillator::new(10, 30).unwrap(); + assert_eq!(dco.periods(), (10, 30)); + assert_eq!(dco.name(), "DecyclerOscillator"); + assert!(dco.warmup_period() >= 1); + assert!(!dco.is_ready()); + dco.update(100.0); + assert!(dco.is_ready()); + assert!(dco.value().is_some()); + } + + #[test] + fn constant_series_yields_zero() { + let mut dco = DecyclerOscillator::new(10, 30).unwrap(); + let out = dco.batch(&[42.0_f64; 80]); + for x in out.iter().flatten() { + assert_relative_eq!(*x, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..100) + .map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 6.0) + .collect(); + let mut a = DecyclerOscillator::new(10, 30).unwrap(); + let mut b = DecyclerOscillator::new(10, 30).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut dco = DecyclerOscillator::new(10, 30).unwrap(); + dco.batch(&(1..=50).map(f64::from).collect::>()); + let before = dco.value(); + assert!(before.is_some()); + assert_eq!(dco.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut dco = DecyclerOscillator::new(10, 30).unwrap(); + dco.batch(&(1..=50).map(f64::from).collect::>()); + assert!(dco.is_ready()); + dco.reset(); + assert!(!dco.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/ehlers_stochastic.rs b/crates/wickra-core/src/indicators/ehlers_stochastic.rs new file mode 100644 index 00000000..03606c9b --- /dev/null +++ b/crates/wickra-core/src/indicators/ehlers_stochastic.rs @@ -0,0 +1,214 @@ +//! Ehlers Stochastic — Stochastic computed on a Roofing-Filter pre-filtered input. +#![allow(clippy::doc_markdown)] + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::roofing_filter::RoofingFilter; +use crate::traits::Indicator; + +/// Ehlers' Adaptive Stochastic. +/// +/// Implements the construction described in *Cycle Analytics for Traders* +/// (Ehlers 2013, ch. 7): the raw price is first passed through a +/// [`RoofingFilter`] (high-pass + SuperSmoother bandpass) to isolate the +/// tradable cycle band, then the classic Stochastic %K formula is applied +/// to the filtered output over `period` bars and finally re-smoothed by a +/// 2-bar SuperSmoother. The result is a ±1-normalised oscillator that +/// reacts to cycles without trending bias from low-frequency drift. +/// +/// The output uses Ehlers' `2 * (X - MinX) / (MaxX - MinX) - 1` convention, +/// so the range is `[-1, +1]` rather than the conventional `[0, 100]`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, EhlersStochastic}; +/// +/// let mut es = EhlersStochastic::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = es.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct EhlersStochastic { + period: usize, + roofing: RoofingFilter, + filtered_buf: VecDeque, + // Tiny 2-tap IIR (Ehlers uses a simple SMA(2) for the final smoothing). + prev_stoch: f64, + has_prev: bool, + last_value: Option, +} + +impl EhlersStochastic { + /// Construct with the rolling window length used by the inner stochastic. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + // Defaults match Ehlers' (10, 48) roofing filter cutoffs. + roofing: RoofingFilter::new(10, 48)?, + filtered_buf: VecDeque::with_capacity(period), + prev_stoch: 0.0, + has_prev: false, + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for EhlersStochastic { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + let filtered = self.roofing.update(input)?; + if self.filtered_buf.len() == self.period { + self.filtered_buf.pop_front(); + } + self.filtered_buf.push_back(filtered); + if self.filtered_buf.len() < self.period { + return None; + } + let max = self + .filtered_buf + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let min = self + .filtered_buf + .iter() + .copied() + .fold(f64::INFINITY, f64::min); + let range = max - min; + let raw = if range > 0.0 { + ((filtered - min) / range).mul_add(2.0, -1.0) + } else { + 0.0 + }; + // 2-bar SMA smoothing. + let smoothed = if self.has_prev { + 0.5 * (raw + self.prev_stoch) + } else { + raw + }; + self.prev_stoch = raw; + self.has_prev = true; + self.last_value = Some(smoothed); + Some(smoothed) + } + + fn reset(&mut self) { + self.roofing.reset(); + self.filtered_buf.clear(); + self.prev_stoch = 0.0; + self.has_prev = false; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.period + self.roofing.warmup_period() + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "EhlersStochastic" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(EhlersStochastic::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut es = EhlersStochastic::new(20).unwrap(); + assert_eq!(es.period(), 20); + assert_eq!(es.warmup_period(), 22); + assert_eq!(es.name(), "EhlersStochastic"); + assert!(!es.is_ready()); + let prices: Vec = (0..150) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + es.batch(&prices); + assert!(es.is_ready()); + assert!(es.value().is_some()); + } + + #[test] + fn output_bounded_in_unit_interval() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut es = EhlersStochastic::new(20).unwrap(); + for v in es.batch(&prices).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v), "value out of band: {v}"); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..150) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut a = EhlersStochastic::new(20).unwrap(); + let mut b = EhlersStochastic::new(20).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut es = EhlersStochastic::new(20).unwrap(); + let prices: Vec = (0..150) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + es.batch(&prices); + let before = es.value(); + assert!(before.is_some()); + assert_eq!(es.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut es = EhlersStochastic::new(20).unwrap(); + let prices: Vec = (0..150) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + es.batch(&prices); + assert!(es.is_ready()); + es.reset(); + assert!(!es.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/empirical_mode_decomposition.rs b/crates/wickra-core/src/indicators/empirical_mode_decomposition.rs new file mode 100644 index 00000000..e0e3fd2d --- /dev/null +++ b/crates/wickra-core/src/indicators/empirical_mode_decomposition.rs @@ -0,0 +1,266 @@ +//! Ehlers Empirical Mode Decomposition (bandpass + envelope). + +use std::collections::VecDeque; +use std::f64::consts::PI; + +use crate::error::{Error, Result}; +use crate::indicators::super_smoother::SuperSmoother; +use crate::traits::Indicator; + +/// Ehlers' adaptation of Empirical Mode Decomposition (EMD). +/// +/// Implementation per *Cycle Analytics for Traders* (Ehlers 2013, ch. 14). +/// The procedure is: +/// +/// 1. Apply a bandpass filter centred on `period` to the price. +/// 2. Detect peaks and valleys of the bandpassed signal over a `fraction` +/// of the period. +/// 3. Average the peaks and valleys separately to form an upper / lower +/// envelope, then return the centred bandpass minus the envelope mean +/// (the "EMD" line). +/// +/// The output crosses zero at trend changes and stays near zero in +/// non-trending markets — the classic visual cue Ehlers documents. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, EmpiricalModeDecomposition}; +/// +/// let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap(); +/// let mut last = None; +/// for i in 0..200 { +/// last = emd.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct EmpiricalModeDecomposition { + period: usize, + fraction: f64, + bandpass: f64, + prev_bp_1: f64, + prev_bp_2: f64, + prev_in_1: Option, + prev_in_2: Option, + beta: f64, + alpha: f64, + smoother: SuperSmoother, + peak_smoother: SuperSmoother, + valley_smoother: SuperSmoother, + bp_buf: VecDeque, + bp_history_len: usize, + last_value: Option, +} + +impl EmpiricalModeDecomposition { + /// Construct with the bandpass centre period and the peak-detection + /// window fraction. + /// + /// `fraction` is multiplied by `period` to size the rolling peak/valley + /// window; Ehlers recommends `0.5`. Both must be positive. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`, and + /// [`Error::InvalidPeriod`] if `fraction <= 0` or non-finite. + pub fn new(period: usize, fraction: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 { + return Err(Error::InvalidPeriod { + message: "fraction must be in (0, 1]", + }); + } + let beta = (2.0 * PI / period as f64).cos(); + let gamma = 1.0 / (2.0 * PI * 0.25 / period as f64).cos(); + let alpha = gamma - (gamma * gamma - 1.0).sqrt(); + let history = (period as f64 * fraction).round().max(1.0) as usize; + Ok(Self { + period, + fraction, + bandpass: 0.0, + prev_bp_1: 0.0, + prev_bp_2: 0.0, + prev_in_1: None, + prev_in_2: None, + beta, + alpha, + smoother: SuperSmoother::new(period.max(2))?, + peak_smoother: SuperSmoother::new(period.max(2))?, + valley_smoother: SuperSmoother::new(period.max(2))?, + bp_buf: VecDeque::with_capacity(history), + bp_history_len: history, + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured fraction. + pub const fn fraction(&self) -> f64 { + self.fraction + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for EmpiricalModeDecomposition { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + // 2nd-order resonant bandpass per Ehlers ch. 6. + let bp = if let (Some(_x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) { + 0.5 * (1.0 - self.alpha) * (input - x2) + + self.beta * (1.0 + self.alpha) * self.prev_bp_1 + - self.alpha * self.prev_bp_2 + } else { + 0.0 + }; + self.prev_bp_2 = self.prev_bp_1; + self.prev_bp_1 = bp; + self.bandpass = bp; + self.prev_in_2 = self.prev_in_1; + self.prev_in_1 = Some(input); + + if self.bp_buf.len() == self.bp_history_len { + self.bp_buf.pop_front(); + } + self.bp_buf.push_back(bp); + if self.bp_buf.len() < self.bp_history_len { + return None; + } + + // Identify the current peak (largest), valley (smallest) within the window. + let peak = self + .bp_buf + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let valley = self.bp_buf.iter().copied().fold(f64::INFINITY, f64::min); + + let avg_peak = self.peak_smoother.update(peak)?; + let avg_valley = self.valley_smoother.update(valley)?; + + // The EMD line is the bandpass minus the smoothed mean envelope. + let mean = 0.5 * (avg_peak + avg_valley); + let raw = bp - mean; + let v = self.smoother.update(raw)?; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.bandpass = 0.0; + self.prev_bp_1 = 0.0; + self.prev_bp_2 = 0.0; + self.prev_in_1 = None; + self.prev_in_2 = None; + self.smoother.reset(); + self.peak_smoother.reset(); + self.valley_smoother.reset(); + self.bp_buf.clear(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.bp_history_len + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "EmpiricalModeDecomposition" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn new_rejects_invalid_params() { + assert!(matches!( + EmpiricalModeDecomposition::new(0, 0.5), + Err(Error::PeriodZero) + )); + assert!(matches!( + EmpiricalModeDecomposition::new(20, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + EmpiricalModeDecomposition::new(20, 1.5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + EmpiricalModeDecomposition::new(20, f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap(); + assert_eq!(emd.period(), 20); + assert!((emd.fraction() - 0.5).abs() < 1e-15); + assert_eq!(emd.name(), "EmpiricalModeDecomposition"); + assert!(emd.warmup_period() >= 1); + assert!(!emd.is_ready()); + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + emd.batch(&prices); + assert!(emd.is_ready()); + assert!(emd.value().is_some()); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 5.0) + .collect(); + let mut a = EmpiricalModeDecomposition::new(20, 0.5).unwrap(); + let mut b = EmpiricalModeDecomposition::new(20, 0.5).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap(); + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + emd.batch(&prices); + let before = emd.value(); + assert!(before.is_some()); + assert_eq!(emd.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap(); + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + emd.batch(&prices); + assert!(emd.is_ready()); + emd.reset(); + assert!(!emd.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/fama.rs b/crates/wickra-core/src/indicators/fama.rs new file mode 100644 index 00000000..631d85e7 --- /dev/null +++ b/crates/wickra-core/src/indicators/fama.rs @@ -0,0 +1,161 @@ +//! Ehlers Following Adaptive Moving Average (FAMA). + +use crate::error::Result; +use crate::indicators::mama::Mama; +use crate::traits::Indicator; + +/// Scalar wrapper that exposes only the FAMA line from a [`Mama`] indicator. +/// +/// FAMA (Following Adaptive Moving Average) is MAMA's lagging companion in +/// Ehlers' MESA construction. It uses half MAMA's adaptive alpha, so it +/// reacts later than MAMA — MAMA crossing above FAMA marks a trend +/// confirmation, MAMA below FAMA a reversal. See [`Mama`] for the joint +/// `(mama, fama)` output; this wrapper exposes the slow line as a plain +/// scalar indicator so it can be chained directly. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Fama}; +/// +/// let mut fama = Fama::new(0.5, 0.05).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Fama { + inner: Mama, + last_value: Option, +} + +impl Fama { + /// Construct with the same `(fast_limit, slow_limit)` semantics as [`Mama`]. + /// + /// # Errors + /// + /// Forwards [`Mama::new`]'s validation errors. + pub fn new(fast_limit: f64, slow_limit: f64) -> Result { + Ok(Self { + inner: Mama::new(fast_limit, slow_limit)?, + last_value: None, + }) + } + + /// Default `(0.5, 0.05)` parameters. + pub fn classic() -> Self { + Self { + inner: Mama::classic(), + last_value: None, + } + } + + /// Configured `(fast_limit, slow_limit)`. + pub const fn limits(&self) -> (f64, f64) { + self.inner.limits() + } + + /// Current FAMA value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for Fama { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let v = self.inner.update(input)?.fama; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.inner.reset(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "FAMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::Error; + use crate::traits::BatchExt; + + #[test] + fn rejects_invalid_limits() { + assert!(matches!( + Fama::new(0.0, 0.05), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Fama::new(0.05, 0.5), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let mut fama = Fama::classic(); + assert_eq!(fama.limits(), (0.5, 0.05)); + assert_eq!(fama.warmup_period(), 33); + assert_eq!(fama.name(), "FAMA"); + assert!(!fama.is_ready()); + for i in 0..60 { + fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); + } + assert!(fama.is_ready()); + assert!(fama.value().is_some()); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).cos() * 5.0) + .collect(); + let mut a = Fama::classic(); + let mut b = Fama::classic(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut fama = Fama::classic(); + let prices: Vec = (0..100) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + fama.batch(&prices); + let before = fama.value(); + assert!(before.is_some()); + assert_eq!(fama.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut fama = Fama::classic(); + let prices: Vec = (0..100) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + fama.batch(&prices); + assert!(fama.is_ready()); + fama.reset(); + assert!(!fama.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/fisher_transform.rs b/crates/wickra-core/src/indicators/fisher_transform.rs new file mode 100644 index 00000000..1b7876ff --- /dev/null +++ b/crates/wickra-core/src/indicators/fisher_transform.rs @@ -0,0 +1,199 @@ +//! Ehlers Fisher Transform. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' Fisher Transform of price. +/// +/// Normalises the most recent price to `[-1, +1]` via min/max over a `period` +/// window, smooths the normalised value with a 0.33 / 0.67 IIR step, and +/// applies the Fisher transform `0.5 * ln((1+x)/(1-x))`. The result has a +/// near-Gaussian distribution, so extreme readings stand out cleanly. A +/// secondary signal is produced by lagging the Fisher value by one bar (the +/// classic trigger), making the indicator a two-line crossover system in +/// charts. +/// +/// Only the primary Fisher value is exposed here as a scalar; the lagged +/// trigger is one update behind by construction. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, FisherTransform}; +/// +/// let mut ft = FisherTransform::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// last = ft.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct FisherTransform { + period: usize, + window: VecDeque, + smoothed: f64, + last_fisher: Option, +} + +impl FisherTransform { + /// Construct with the rolling extrema window length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + smoothed: 0.0, + last_fisher: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current Fisher value if available. + pub const fn value(&self) -> Option { + self.last_fisher + } +} + +impl Indicator for FisherTransform { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_fisher; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let max = self + .window + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let min = self.window.iter().copied().fold(f64::INFINITY, f64::min); + let range = max - min; + // Normalise to roughly [-1, +1]; centred midpoint when range == 0. + let raw = if range > 0.0 { + ((input - min) / range).mul_add(2.0, -1.0) + } else { + 0.0 + }; + // Ehlers IIR: 0.33 * raw + 0.67 * prev_smoothed, then clamp. + self.smoothed = 0.33f64.mul_add(raw, 0.67 * self.smoothed); + // Clamp strictly inside (-1, +1) to keep the log finite. + let clamped = self.smoothed.clamp(-0.999, 0.999); + let fisher = 0.5 * ((1.0 + clamped) / (1.0 - clamped)).ln(); + self.last_fisher = Some(fisher); + Some(fisher) + } + + fn reset(&mut self) { + self.window.clear(); + self.smoothed = 0.0; + self.last_fisher = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last_fisher.is_some() + } + + fn name(&self) -> &'static str { + "FisherTransform" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(FisherTransform::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut ft = FisherTransform::new(10).unwrap(); + assert_eq!(ft.period(), 10); + assert_eq!(ft.warmup_period(), 10); + assert_eq!(ft.name(), "FisherTransform"); + assert!(ft.value().is_none()); + for i in 1..=10 { + ft.update(f64::from(i)); + } + assert!(ft.value().is_some()); + assert!(ft.is_ready()); + } + + #[test] + fn warmup_returns_none_until_seed() { + let mut ft = FisherTransform::new(5).unwrap(); + for i in 1..=4 { + assert_eq!(ft.update(f64::from(i)), None); + } + assert!(ft.update(5.0).is_some()); + } + + #[test] + fn constant_series_zero_range_yields_zero() { + let mut ft = FisherTransform::new(5).unwrap(); + let out = ft.batch(&[42.0_f64; 30]); + for x in out.iter().skip(5).flatten() { + assert!(x.abs() < 1e-6, "expected near-zero, got {x}"); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 8.0) + .collect(); + let mut a = FisherTransform::new(10).unwrap(); + let mut b = FisherTransform::new(10).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut ft = FisherTransform::new(5).unwrap(); + ft.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let before = ft.value(); + assert!(before.is_some()); + assert_eq!(ft.update(f64::NAN), before); + assert_eq!(ft.update(f64::INFINITY), before); + } + + #[test] + fn reset_clears_state() { + let mut ft = FisherTransform::new(5).unwrap(); + ft.batch(&(1..=20).map(f64::from).collect::>()); + assert!(ft.is_ready()); + ft.reset(); + assert!(!ft.is_ready()); + assert_eq!(ft.update(1.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/hilbert_dominant_cycle.rs b/crates/wickra-core/src/indicators/hilbert_dominant_cycle.rs new file mode 100644 index 00000000..1de7f9bf --- /dev/null +++ b/crates/wickra-core/src/indicators/hilbert_dominant_cycle.rs @@ -0,0 +1,272 @@ +//! Ehlers Hilbert Transform Dominant Cycle period estimator. +#![allow(clippy::manual_clamp)] + +use std::f64::consts::PI; + +use crate::traits::Indicator; + +/// Ehlers' Hilbert Transform–based Dominant Cycle period estimator. +/// +/// Decomposes price into in-phase and quadrature components via Ehlers' +/// truncated Hilbert transform, then derives the instantaneous phase. The +/// dominant cycle period is recovered from the phase rate of change and +/// median-smoothed. From *Rocket Science for Traders* (Ehlers 2001, ch. 7), +/// implementation aligned with the formulation used in TA-Lib's `HT_DCPERIOD`. +/// +/// The output is clamped to the band `[6, 50]` bars, which Ehlers identifies +/// as the meaningful tradable cycle range. The estimator emits its first +/// value after ~50 inputs as the moving-average chain fills. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, HilbertDominantCycle}; +/// +/// let mut ht = HilbertDominantCycle::new(); +/// let mut last = None; +/// for i in 0..200 { +/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HilbertDominantCycle { + // Rolling 7-tap smoother input buffer. + smooth_buf: Vec, + // Detrender / Q1 / I1 ring history (need 6 prior). + detrender_buf: Vec, + q1_buf: Vec, + i1_buf: Vec, + // Smoothed I/Q lines for phase computation. + prev_i2: f64, + prev_q2: f64, + prev_re: f64, + prev_im: f64, + prev_period: f64, + prev_smooth_period: f64, + count: usize, + last_value: Option, +} + +impl HilbertDominantCycle { + /// Construct a new dominant cycle estimator. + pub fn new() -> Self { + Self::default() + } + + /// Current period estimate if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for HilbertDominantCycle { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + self.count += 1; + + // 4-bar weighted moving average of the input (smoothed price). + // Ehlers: (4*x[0] + 3*x[1] + 2*x[2] + x[3]) / 10. + Self::push_front(&mut self.smooth_buf, input, 7); + if self.smooth_buf.len() < 4 { + return None; + } + let smooth = (4.0 * self.smooth_buf[0] + + 3.0 * self.smooth_buf[1] + + 2.0 * self.smooth_buf[2] + + self.smooth_buf[3]) + / 10.0; + + // Adaptive coefficient based on the previous period estimate. + let period = self.prev_period.max(6.0).min(50.0); + let adj = 0.075 * period + 0.54; + + // We need the smooth buffer to hold ≥ 7 samples for the Hilbert taps. + if self.smooth_buf.len() < 7 { + return None; + } + + // Ehlers' Hilbert transform of `smooth` (using current + 2/4/6 lags). + let s0 = smooth; + let s2 = self.smooth_buf[2]; + let s4 = self.smooth_buf[4]; + let s6 = self.smooth_buf[6]; + let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj; + Self::push_front(&mut self.detrender_buf, detrender, 7); + + if self.detrender_buf.len() < 7 { + return None; + } + // In-phase and quadrature components. + let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2] + - 0.5769 * self.detrender_buf[4] + - 0.0962 * self.detrender_buf[6]) + * adj; + let i1 = self.detrender_buf[3]; + + Self::push_front(&mut self.q1_buf, q1, 7); + Self::push_front(&mut self.i1_buf, i1, 7); + if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 { + return None; + } + + // Advance the phase 90 deg via a second Hilbert pass. + let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2] + - 0.5769 * self.i1_buf[4] + - 0.0962 * self.i1_buf[6]) + * adj; + let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2] + - 0.5769 * self.q1_buf[4] + - 0.0962 * self.q1_buf[6]) + * adj; + + // Phasor smoothing. + let mut i2 = i1 - jq; + let mut q2 = q1 + ji; + i2 = 0.2 * i2 + 0.8 * self.prev_i2; + q2 = 0.2 * q2 + 0.8 * self.prev_q2; + + // Homodyne discriminator. + let mut re = i2 * self.prev_i2 + q2 * self.prev_q2; + let mut im = i2 * self.prev_q2 - q2 * self.prev_i2; + re = 0.2 * re + 0.8 * self.prev_re; + im = 0.2 * im + 0.8 * self.prev_im; + + self.prev_i2 = i2; + self.prev_q2 = q2; + self.prev_re = re; + self.prev_im = im; + + let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON { + 2.0 * PI / im.atan2(re) + } else { + self.prev_period + }; + // Rate-of-change clamp per Ehlers. + new_period = new_period.min(1.5 * self.prev_period); + new_period = new_period.max(0.67 * self.prev_period); + new_period = new_period.clamp(6.0, 50.0); + + // EMA smoothing of the period. + self.prev_period = 0.2 * new_period + 0.8 * self.prev_period; + // Second smoothing step (TA-Lib uses 0.33/0.67). + self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period; + + if self.count < 50 { + return None; + } + self.last_value = Some(self.prev_smooth_period); + Some(self.prev_smooth_period) + } + + fn reset(&mut self) { + self.smooth_buf.clear(); + self.detrender_buf.clear(); + self.q1_buf.clear(); + self.i1_buf.clear(); + self.prev_i2 = 0.0; + self.prev_q2 = 0.0; + self.prev_re = 0.0; + self.prev_im = 0.0; + self.prev_period = 0.0; + self.prev_smooth_period = 0.0; + self.count = 0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 50 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "HilbertDominantCycle" + } +} + +impl HilbertDominantCycle { + /// Push `v` at the front of `buf`, capping the length at `cap`. + fn push_front(buf: &mut Vec, v: f64, cap: usize) { + buf.insert(0, v); + if buf.len() > cap { + buf.truncate(cap); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn accessors_and_metadata() { + let mut ht = HilbertDominantCycle::new(); + assert_eq!(ht.warmup_period(), 50); + assert_eq!(ht.name(), "HilbertDominantCycle"); + assert!(!ht.is_ready()); + assert!(ht.value().is_none()); + for i in 0..120 { + ht.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); + } + assert!(ht.is_ready()); + assert!(ht.value().is_some()); + } + + #[test] + fn output_within_clamp_band() { + let mut ht = HilbertDominantCycle::new(); + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + let out = ht.batch(&prices); + for v in out.iter().flatten() { + assert!((6.0..=50.0).contains(v), "period {v} outside [6, 50]"); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut a = HilbertDominantCycle::new(); + let mut b = HilbertDominantCycle::new(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut ht = HilbertDominantCycle::new(); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + ht.batch(&prices); + let before = ht.value(); + assert!(before.is_some()); + assert_eq!(ht.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut ht = HilbertDominantCycle::new(); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + ht.batch(&prices); + assert!(ht.is_ready()); + ht.reset(); + assert!(!ht.is_ready()); + assert!(ht.value().is_none()); + } +} diff --git a/crates/wickra-core/src/indicators/instantaneous_trendline.rs b/crates/wickra-core/src/indicators/instantaneous_trendline.rs new file mode 100644 index 00000000..85e107e1 --- /dev/null +++ b/crates/wickra-core/src/indicators/instantaneous_trendline.rs @@ -0,0 +1,213 @@ +//! Ehlers Instantaneous Trendline (ITrend). +#![allow(clippy::doc_markdown)] + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' Instantaneous Trendline (ITrend). +/// +/// A 2-pole IIR that approximates a lag-free trend line: +/// +/// ```text +/// itrend[t] = (alpha - alpha^2/4) * x[t] +/// + 0.5 * alpha^2 * x[t-1] +/// - (alpha - 0.75*alpha^2) * x[t-2] +/// + 2*(1 - alpha) * itrend[t-1] +/// - (1 - alpha)^2 * itrend[t-2] +/// ``` +/// +/// where `alpha = 2 / (period + 1)`. From *Cybernetic Analysis for Stocks +/// and Futures* (Ehlers 2004, ch. 8). During the first six bars the output +/// uses the EasyLanguage initial condition `(x[t] + 2*x[t-1] + x[t-2]) / 4`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, InstantaneousTrendline}; +/// +/// let mut it = InstantaneousTrendline::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = it.update(100.0 + f64::from(i) * 0.5); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct InstantaneousTrendline { + period: usize, + alpha: f64, + in_buf: [Option; 3], + out_buf: [Option; 2], + count: usize, + last_value: Option, +} + +impl InstantaneousTrendline { + /// Construct with the dominant-cycle period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let alpha = 2.0 / (period as f64 + 1.0); + Ok(Self { + period, + alpha, + in_buf: [None; 3], + out_buf: [None; 2], + count: 0, + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Smoothing alpha. + pub const fn alpha(&self) -> f64 { + self.alpha + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for InstantaneousTrendline { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + self.count += 1; + + // Shift input buffer (position 0 = most recent). + self.in_buf[2] = self.in_buf[1]; + self.in_buf[1] = self.in_buf[0]; + self.in_buf[0] = Some(input); + + let alpha = self.alpha; + let v = if self.count >= 7 { + // Full recursive formula. + let (x0, x1, x2) = ( + self.in_buf[0].expect("filled"), + self.in_buf[1].expect("filled"), + self.in_buf[2].expect("filled"), + ); + let (y1, y2) = ( + self.out_buf[0].expect("filled"), + self.out_buf[1].expect("filled"), + ); + (alpha - alpha * alpha / 4.0) * x0 + 0.5 * alpha * alpha * x1 + - (alpha - 0.75 * alpha * alpha) * x2 + + 2.0 * (1.0 - alpha) * y1 + - (1.0 - alpha) * (1.0 - alpha) * y2 + } else { + // Initial condition: 4-point weighted average of the most recent + // inputs (Ehlers EasyLanguage default). + let x0 = self.in_buf[0].expect("just pushed"); + let x1 = self.in_buf[1].unwrap_or(x0); + let x2 = self.in_buf[2].unwrap_or(x0); + (x0 + 2.0 * x1 + x2) / 4.0 + }; + + self.out_buf[1] = self.out_buf[0]; + self.out_buf[0] = Some(v); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.in_buf = [None; 3]; + self.out_buf = [None; 2]; + self.count = 0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "InstantaneousTrendline" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!( + InstantaneousTrendline::new(0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let mut it = InstantaneousTrendline::new(20).unwrap(); + assert_eq!(it.period(), 20); + assert_relative_eq!(it.alpha(), 2.0 / 21.0, epsilon = 1e-15); + assert_eq!(it.warmup_period(), 1); + assert_eq!(it.name(), "InstantaneousTrendline"); + assert!(!it.is_ready()); + it.update(100.0); + assert!(it.is_ready()); + } + + #[test] + fn constant_series_passes_through() { + // Coefficients sum to 1, so a flat input stays flat after warmup. + let mut it = InstantaneousTrendline::new(20).unwrap(); + let out = it.batch(&[42.0_f64; 200]); + for x in out.iter().skip(20).flatten() { + assert_relative_eq!(*x, 42.0, epsilon = 1e-6); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 5.0) + .collect(); + let mut a = InstantaneousTrendline::new(15).unwrap(); + let mut b = InstantaneousTrendline::new(15).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut it = InstantaneousTrendline::new(20).unwrap(); + it.batch(&(1..=40).map(f64::from).collect::>()); + let before = it.value(); + assert!(before.is_some()); + assert_eq!(it.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut it = InstantaneousTrendline::new(20).unwrap(); + it.batch(&(1..=40).map(f64::from).collect::>()); + assert!(it.is_ready()); + it.reset(); + assert!(!it.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/inverse_fisher_transform.rs b/crates/wickra-core/src/indicators/inverse_fisher_transform.rs new file mode 100644 index 00000000..2dc9c481 --- /dev/null +++ b/crates/wickra-core/src/indicators/inverse_fisher_transform.rs @@ -0,0 +1,164 @@ +//! Inverse Fisher Transform (Ehlers). + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Inverse Fisher Transform of a scaled scalar input. +/// +/// Compresses the input through `(e^{2x} - 1) / (e^{2x} + 1) = tanh(x)`, the +/// algebraic inverse of the Fisher transform. The output is bounded in +/// `[-1, +1]` (saturating to exactly `±1` for `|scale * input| >= ~19.06` +/// under IEEE 754 doubles), which makes overbought/oversold thresholds at, say, `±0.5` +/// universal across markets and timeframes — the classic use described by +/// Ehlers in *Cybernetic Analysis for Stocks and Futures* (2004). +/// +/// The constructor takes a `scale` multiplier so callers can feed raw +/// oscillator readings (e.g. RSI in `[0, 100]`, mapped to `[-5, +5]` with +/// `scale = 0.1` after a `-50` shift) without writing their own scaler. +/// Internally the indicator just computes `tanh(scale * input)`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, InverseFisherTransform}; +/// +/// let mut ift = InverseFisherTransform::new(1.0).unwrap(); +/// // Large positive input saturates to +1, large negative to -1. +/// assert!(ift.update(10.0).unwrap() > 0.999); +/// assert!(ift.update(-10.0).unwrap() < -0.999); +/// ``` +#[derive(Debug, Clone)] +pub struct InverseFisherTransform { + scale: f64, + last_value: Option, +} + +impl InverseFisherTransform { + /// Construct with a multiplicative scale applied before the tanh squash. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `scale` is not finite or non-positive. + pub fn new(scale: f64) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err(Error::InvalidPeriod { + message: "scale must be a positive finite number", + }); + } + Ok(Self { + scale, + last_value: None, + }) + } + + /// Configured scale. + pub const fn scale(&self) -> f64 { + self.scale + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for InverseFisherTransform { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + let scaled = self.scale * input; + // tanh is numerically safe for any finite input. + let v = scaled.tanh(); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "InverseFisherTransform" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_non_positive_scale() { + assert!(matches!( + InverseFisherTransform::new(0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + InverseFisherTransform::new(-1.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + InverseFisherTransform::new(f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let mut ift = InverseFisherTransform::new(0.5).unwrap(); + assert_relative_eq!(ift.scale(), 0.5, epsilon = 1e-15); + assert_eq!(ift.warmup_period(), 1); + assert_eq!(ift.name(), "InverseFisherTransform"); + assert!(!ift.is_ready()); + assert!(ift.update(1.0).is_some()); + assert!(ift.is_ready()); + assert!(ift.value().is_some()); + } + + #[test] + fn zero_input_yields_zero() { + let mut ift = InverseFisherTransform::new(1.0).unwrap(); + assert_relative_eq!(ift.update(0.0).unwrap(), 0.0, epsilon = 1e-15); + } + + #[test] + fn output_bounded_in_closed_unit_interval() { + // tanh saturates to exactly ±1.0 in IEEE 754 once |x| >= ~19.06, so the + // output is in the closed interval [-1, +1] rather than strictly open. + let mut ift = InverseFisherTransform::new(1.0).unwrap(); + for i in -100..=100 { + let v = ift.update(f64::from(i)).unwrap(); + assert!((-1.0..=1.0).contains(&v), "v={v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut ift = InverseFisherTransform::new(1.0).unwrap(); + ift.update(2.0); + assert!(ift.is_ready()); + ift.reset(); + assert!(!ift.is_ready()); + } + + #[test] + fn ignores_non_finite_input() { + let mut ift = InverseFisherTransform::new(1.0).unwrap(); + ift.update(1.0); + let before = ift.value(); + assert_eq!(ift.update(f64::NAN), before); + assert_eq!(ift.update(f64::INFINITY), before); + } +} diff --git a/crates/wickra-core/src/indicators/mama.rs b/crates/wickra-core/src/indicators/mama.rs new file mode 100644 index 00000000..f6aa87c8 --- /dev/null +++ b/crates/wickra-core/src/indicators/mama.rs @@ -0,0 +1,370 @@ +//! Ehlers MESA Adaptive Moving Average (MAMA) and its follower (FAMA). +#![allow( + clippy::doc_markdown, + clippy::doc_lazy_continuation, + clippy::struct_field_names, + clippy::manual_clamp +)] + +use std::f64::consts::PI; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// MAMA + FAMA output pair. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MamaOutput { + /// MESA Adaptive Moving Average. + pub mama: f64, + /// Following Adaptive Moving Average (slower companion). + pub fama: f64, +} + +/// Ehlers' MESA Adaptive Moving Average (MAMA). +/// +/// MAMA adapts its smoothing constant from the rate-of-change of price phase, +/// derived via a truncated Hilbert transform — full math in "Cycle Analytics +/// for Traders" (Ehlers 2013, ch. 8) and the original 2001 MESA paper. +/// +/// The two-parameter `(fast_limit, slow_limit)` is the range over which the +/// adaptive alpha can vary; defaults `(0.5, 0.05)` match the canonical +/// EasyLanguage implementation. The companion FAMA is `mama * 0.5 * fast_limit +/// + fama_prev * (1 - 0.5 * fast_limit)`, lagging MAMA so crossovers signal +/// trend reversals. +/// +/// The indicator emits both lines as a [`MamaOutput`]. Use the [`Fama`] wrapper +/// in this module to expose just the slow line if needed (e.g. for chaining). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Mama}; +/// +/// let mut mama = Mama::new(0.5, 0.05).unwrap(); +/// let mut last = None; +/// for i in 0..100 { +/// last = mama.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Mama { + fast_limit: f64, + slow_limit: f64, + smooth_buf: Vec, + detrender_buf: Vec, + q1_buf: Vec, + i1_buf: Vec, + prev_i2: f64, + prev_q2: f64, + prev_re: f64, + prev_im: f64, + prev_period: f64, + prev_phase: f64, + prev_mama: f64, + prev_fama: f64, + count: usize, + last_value: Option, +} + +impl Mama { + /// Construct with custom `(fast_limit, slow_limit)` adaptive alpha bounds. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if either limit is outside `(0, 1]` + /// or if `slow_limit > fast_limit`. + pub fn new(fast_limit: f64, slow_limit: f64) -> Result { + if !fast_limit.is_finite() + || !slow_limit.is_finite() + || fast_limit <= 0.0 + || fast_limit > 1.0 + || slow_limit <= 0.0 + || slow_limit > 1.0 + || slow_limit > fast_limit + { + return Err(Error::InvalidPeriod { + message: "fast_limit, slow_limit must satisfy 0 < slow_limit <= fast_limit <= 1", + }); + } + Ok(Self { + fast_limit, + slow_limit, + smooth_buf: Vec::with_capacity(7), + detrender_buf: Vec::with_capacity(7), + q1_buf: Vec::with_capacity(7), + i1_buf: Vec::with_capacity(7), + prev_i2: 0.0, + prev_q2: 0.0, + prev_re: 0.0, + prev_im: 0.0, + prev_period: 0.0, + prev_phase: 0.0, + prev_mama: 0.0, + prev_fama: 0.0, + count: 0, + last_value: None, + }) + } + + /// Default `(0.5, 0.05)` parameters from Ehlers' original publication. + pub fn classic() -> Self { + Self::new(0.5, 0.05).expect("classic MAMA limits are valid") + } + + /// Configured `(fast_limit, slow_limit)`. + pub const fn limits(&self) -> (f64, f64) { + (self.fast_limit, self.slow_limit) + } + + /// Current `(mama, fama)` pair if available. + pub const fn value(&self) -> Option { + self.last_value + } + + fn push_front(buf: &mut Vec, v: f64, cap: usize) { + buf.insert(0, v); + if buf.len() > cap { + buf.truncate(cap); + } + } +} + +impl Indicator for Mama { + type Input = f64; + type Output = MamaOutput; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + self.count += 1; + + Self::push_front(&mut self.smooth_buf, input, 7); + if self.smooth_buf.len() < 4 { + return None; + } + let smooth = (4.0 * self.smooth_buf[0] + + 3.0 * self.smooth_buf[1] + + 2.0 * self.smooth_buf[2] + + self.smooth_buf[3]) + / 10.0; + + let period = self.prev_period.max(6.0).min(50.0); + let adj = 0.075 * period + 0.54; + + if self.smooth_buf.len() < 7 { + // Seed the EMA outputs with the smoothed price so early bars are + // well-behaved without producing a public value. + self.prev_mama = smooth; + self.prev_fama = smooth; + return None; + } + let s0 = smooth; + let s2 = self.smooth_buf[2]; + let s4 = self.smooth_buf[4]; + let s6 = self.smooth_buf[6]; + let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj; + Self::push_front(&mut self.detrender_buf, detrender, 7); + if self.detrender_buf.len() < 7 { + return None; + } + + let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2] + - 0.5769 * self.detrender_buf[4] + - 0.0962 * self.detrender_buf[6]) + * adj; + let i1 = self.detrender_buf[3]; + Self::push_front(&mut self.q1_buf, q1, 7); + Self::push_front(&mut self.i1_buf, i1, 7); + if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 { + return None; + } + + let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2] + - 0.5769 * self.i1_buf[4] + - 0.0962 * self.i1_buf[6]) + * adj; + let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2] + - 0.5769 * self.q1_buf[4] + - 0.0962 * self.q1_buf[6]) + * adj; + + let mut i2 = i1 - jq; + let mut q2 = q1 + ji; + i2 = 0.2 * i2 + 0.8 * self.prev_i2; + q2 = 0.2 * q2 + 0.8 * self.prev_q2; + + let mut re = i2 * self.prev_i2 + q2 * self.prev_q2; + let mut im = i2 * self.prev_q2 - q2 * self.prev_i2; + re = 0.2 * re + 0.8 * self.prev_re; + im = 0.2 * im + 0.8 * self.prev_im; + + self.prev_i2 = i2; + self.prev_q2 = q2; + self.prev_re = re; + self.prev_im = im; + + let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON { + 2.0 * PI / im.atan2(re) + } else { + self.prev_period + }; + new_period = new_period.min(1.5 * self.prev_period); + new_period = new_period.max(0.67 * self.prev_period); + new_period = new_period.clamp(6.0, 50.0); + self.prev_period = 0.2 * new_period + 0.8 * self.prev_period; + + // Adaptive alpha derived from phase rate-of-change. + let phase = if i1.abs() > f64::EPSILON { + (q1 / i1).atan().to_degrees() + } else { + self.prev_phase + }; + let mut delta_phase = self.prev_phase - phase; + self.prev_phase = phase; + if delta_phase < 1.0 { + delta_phase = 1.0; + } + let mut alpha = self.fast_limit / delta_phase; + if alpha < self.slow_limit { + alpha = self.slow_limit; + } + if alpha > self.fast_limit { + alpha = self.fast_limit; + } + + self.prev_mama = alpha * input + (1.0 - alpha) * self.prev_mama; + let fama_alpha = 0.5 * alpha; + self.prev_fama = fama_alpha * self.prev_mama + (1.0 - fama_alpha) * self.prev_fama; + + if self.count < 33 { + return None; + } + let out = MamaOutput { + mama: self.prev_mama, + fama: self.prev_fama, + }; + self.last_value = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.smooth_buf.clear(); + self.detrender_buf.clear(); + self.q1_buf.clear(); + self.i1_buf.clear(); + self.prev_i2 = 0.0; + self.prev_q2 = 0.0; + self.prev_re = 0.0; + self.prev_im = 0.0; + self.prev_period = 0.0; + self.prev_phase = 0.0; + self.prev_mama = 0.0; + self.prev_fama = 0.0; + self.count = 0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 33 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "MAMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_invalid_limits() { + assert!(matches!( + Mama::new(0.0, 0.05), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Mama::new(0.5, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Mama::new(0.05, 0.5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Mama::new(1.5, 0.05), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Mama::new(f64::NAN, 0.05), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let mut mama = Mama::classic(); + assert_eq!(mama.limits(), (0.5, 0.05)); + assert_eq!(mama.warmup_period(), 33); + assert_eq!(mama.name(), "MAMA"); + assert!(!mama.is_ready()); + for i in 0..60 { + mama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); + } + assert!(mama.is_ready()); + assert!(mama.value().is_some()); + } + + #[test] + fn fama_lags_or_equals_mama_on_constant_series() { + let mut mama = Mama::classic(); + let out = mama.batch(&[100.0_f64; 200]); + let last = out.iter().flatten().last().unwrap(); + // On a flat series both lines converge to the price. + assert!((last.mama - 100.0).abs() < 1.0); + assert!((last.fama - 100.0).abs() < 1.0); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 5.0) + .collect(); + let mut a = Mama::classic(); + let mut b = Mama::classic(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut mama = Mama::classic(); + let prices: Vec = (0..100) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + mama.batch(&prices); + let before = mama.value(); + assert!(before.is_some()); + assert_eq!(mama.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut mama = Mama::classic(); + let prices: Vec = (0..100) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + mama.batch(&prices); + assert!(mama.is_ready()); + mama.reset(); + assert!(!mama.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index ddd55851..75b336c4 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -7,6 +7,7 @@ mod acceleration_bands; mod accelerator_oscillator; mod ad_oscillator; +mod adaptive_cycle; mod adl; mod adx; mod adxr; @@ -26,6 +27,7 @@ mod bollinger; mod bollinger_bandwidth; mod camarilla_pivots; mod cci; +mod center_of_gravity; mod cfo; mod chaikin_oscillator; mod chaikin_volatility; @@ -37,6 +39,9 @@ mod cmf; mod cmo; mod connors_rsi; mod coppock; +mod cybernetic_cycle; +mod decycler; +mod decycler_oscillator; mod dema; mod demand_index; mod demark_pivots; @@ -45,19 +50,26 @@ mod donchian_stop; mod double_bollinger; mod dpo; mod ease_of_movement; +mod ehlers_stochastic; mod elder_impulse; mod ema; +mod empirical_mode_decomposition; mod evwma; +mod fama; mod fibonacci_pivots; +mod fisher_transform; mod force_index; mod fractal_chaos_bands; mod frama; mod garman_klass; +mod hilbert_dominant_cycle; mod hilo_activator; mod historical_volatility; mod hma; mod hurst_channel; mod inertia; +mod instantaneous_trendline; +mod inverse_fisher_transform; mod jma; mod kama; mod keltner; @@ -70,6 +82,7 @@ mod linreg_channel; mod linreg_slope; mod ma_envelope; mod macd; +mod mama; mod market_facilitation_index; mod mass_index; mod mcginley_dynamic; @@ -90,10 +103,12 @@ mod pvi; mod renko_trailing_stop; mod roc; mod rogers_satchell; +mod roofing_filter; mod rsi; mod rvi; mod rvi_volatility; mod rwi; +mod sine_wave; mod sma; mod smi; mod smma; @@ -104,6 +119,7 @@ mod std_dev; mod step_trailing_stop; mod stoch_rsi; mod stochastic; +mod super_smoother; mod super_trend; mod t3; mod td_combo; @@ -155,6 +171,7 @@ mod zlema; pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput}; pub use accelerator_oscillator::AcceleratorOscillator; pub use ad_oscillator::AdOscillator; +pub use adaptive_cycle::AdaptiveCycle; pub use adl::Adl; pub use adx::{Adx, AdxOutput}; pub use adxr::Adxr; @@ -174,6 +191,7 @@ pub use bollinger::{BollingerBands, BollingerOutput}; pub use bollinger_bandwidth::BollingerBandwidth; pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput}; pub use cci::Cci; +pub use center_of_gravity::CenterOfGravity; pub use cfo::Cfo; pub use chaikin_oscillator::ChaikinOscillator; pub use chaikin_volatility::ChaikinVolatility; @@ -185,6 +203,9 @@ pub use cmf::ChaikinMoneyFlow; pub use cmo::Cmo; pub use connors_rsi::ConnorsRsi; pub use coppock::Coppock; +pub use cybernetic_cycle::CyberneticCycle; +pub use decycler::Decycler; +pub use decycler_oscillator::DecyclerOscillator; pub use dema::Dema; pub use demand_index::DemandIndex; pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput}; @@ -193,19 +214,26 @@ pub use donchian_stop::{DonchianStop, DonchianStopOutput}; pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput}; pub use dpo::Dpo; pub use ease_of_movement::EaseOfMovement; +pub use ehlers_stochastic::EhlersStochastic; pub use elder_impulse::ElderImpulse; pub use ema::Ema; +pub use empirical_mode_decomposition::EmpiricalModeDecomposition; pub use evwma::Evwma; +pub use fama::Fama; pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput}; +pub use fisher_transform::FisherTransform; pub use force_index::ForceIndex; pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput}; pub use frama::Frama; pub use garman_klass::GarmanKlassVolatility; +pub use hilbert_dominant_cycle::HilbertDominantCycle; pub use hilo_activator::HiLoActivator; pub use historical_volatility::HistoricalVolatility; pub use hma::Hma; pub use hurst_channel::{HurstChannel, HurstChannelOutput}; pub use inertia::Inertia; +pub use instantaneous_trendline::InstantaneousTrendline; +pub use inverse_fisher_transform::InverseFisherTransform; pub use jma::Jma; pub use kama::Kama; pub use keltner::{Keltner, KeltnerOutput}; @@ -218,6 +246,7 @@ pub use linreg_channel::{LinRegChannel, LinRegChannelOutput}; pub use linreg_slope::LinRegSlope; pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput}; pub use macd::{MacdIndicator, MacdOutput}; +pub use mama::{Mama, MamaOutput}; pub use market_facilitation_index::MarketFacilitationIndex; pub use mass_index::MassIndex; pub use mcginley_dynamic::McGinleyDynamic; @@ -238,10 +267,12 @@ pub use pvi::Pvi; pub use renko_trailing_stop::RenkoTrailingStop; pub use roc::Roc; pub use rogers_satchell::RogersSatchellVolatility; +pub use roofing_filter::RoofingFilter; pub use rsi::Rsi; pub use rvi::Rvi; pub use rvi_volatility::RviVolatility; pub use rwi::{Rwi, RwiOutput}; +pub use sine_wave::SineWave; pub use sma::Sma; pub use smi::Smi; pub use smma::Smma; @@ -252,6 +283,7 @@ pub use std_dev::StdDev; pub use step_trailing_stop::StepTrailingStop; pub use stoch_rsi::StochRsi; pub use stochastic::{Stochastic, StochasticOutput}; +pub use super_smoother::SuperSmoother; pub use super_trend::{SuperTrend, SuperTrendOutput}; pub use t3::T3; pub use td_combo::TdCombo; diff --git a/crates/wickra-core/src/indicators/roofing_filter.rs b/crates/wickra-core/src/indicators/roofing_filter.rs new file mode 100644 index 00000000..01190b0e --- /dev/null +++ b/crates/wickra-core/src/indicators/roofing_filter.rs @@ -0,0 +1,202 @@ +//! Ehlers Roofing Filter (high-pass followed by SuperSmoother). +#![allow(clippy::doc_markdown)] + +use std::f64::consts::PI; + +use crate::error::{Error, Result}; +use crate::indicators::super_smoother::SuperSmoother; +use crate::traits::Indicator; + +/// Ehlers' Roofing Filter — a bandpass formed by feeding a 2-pole high-pass +/// into a [`SuperSmoother`]. +/// +/// Defined in *Cycle Analytics for Traders* (Ehlers 2013, ch. 7) as the +/// canonical pre-filter for cycle-aware oscillators: the high-pass strips out +/// the trend (periods longer than `hp_period`), and the SuperSmoother removes +/// noise (periods shorter than `lp_period`). The result is essentially the +/// 10–48 bar cycle band by default. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RoofingFilter}; +/// +/// let mut rf = RoofingFilter::new(10, 48).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = rf.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RoofingFilter { + lp_period: usize, + hp_period: usize, + alpha: f64, + prev_in_1: Option, + prev_hp_1: f64, + prev_hp_2: f64, + smoother: SuperSmoother, + last_value: Option, +} + +impl RoofingFilter { + /// Construct with `lp_period` (SuperSmoother critical period) and + /// `hp_period` (high-pass cutoff). Defaults in Ehlers are `(10, 48)`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is zero, and + /// [`Error::InvalidPeriod`] if `lp_period >= hp_period`. + pub fn new(lp_period: usize, hp_period: usize) -> Result { + if lp_period == 0 || hp_period == 0 { + return Err(Error::PeriodZero); + } + if lp_period >= hp_period { + return Err(Error::InvalidPeriod { + message: "lp_period must be strictly less than hp_period", + }); + } + // Single-pole high-pass alpha from Ehlers ch. 7. + let arg = 2.0 * PI / hp_period as f64; + let alpha = (arg.cos() + arg.sin() - 1.0) / arg.cos(); + Ok(Self { + lp_period, + hp_period, + alpha, + prev_in_1: None, + prev_hp_1: 0.0, + prev_hp_2: 0.0, + smoother: SuperSmoother::new(lp_period)?, + last_value: None, + }) + } + + /// Configured `(lp_period, hp_period)`. + pub const fn periods(&self) -> (usize, usize) { + (self.lp_period, self.hp_period) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for RoofingFilter { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + let hp = if let Some(x1) = self.prev_in_1 { + let one_minus_half_alpha = 1.0 - self.alpha / 2.0; + one_minus_half_alpha * (input - x1) + (1.0 - self.alpha) * self.prev_hp_1 + } else { + 0.0 + }; + self.prev_hp_2 = self.prev_hp_1; + self.prev_hp_1 = hp; + self.prev_in_1 = Some(input); + let v = self.smoother.update(hp)?; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev_in_1 = None; + self.prev_hp_1 = 0.0; + self.prev_hp_2 = 0.0; + self.smoother.reset(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + // SuperSmoother is ready after one input; we need two to compute HP. + 2 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "RoofingFilter" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_invalid_periods() { + assert!(matches!(RoofingFilter::new(0, 48), Err(Error::PeriodZero))); + assert!(matches!(RoofingFilter::new(10, 0), Err(Error::PeriodZero))); + assert!(matches!( + RoofingFilter::new(48, 10), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + RoofingFilter::new(10, 10), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let mut rf = RoofingFilter::new(10, 48).unwrap(); + assert_eq!(rf.periods(), (10, 48)); + assert_eq!(rf.warmup_period(), 2); + assert_eq!(rf.name(), "RoofingFilter"); + assert!(!rf.is_ready()); + rf.update(100.0); + rf.update(101.0); + assert!(rf.is_ready()); + assert!(rf.value().is_some()); + } + + #[test] + fn constant_series_converges_to_zero() { + // High-pass on a flat input is zero, and the smoother of zero is zero. + let mut rf = RoofingFilter::new(10, 48).unwrap(); + let out = rf.batch(&[42.0_f64; 300]); + for x in out.iter().skip(100).flatten() { + assert_relative_eq!(*x, 0.0, epsilon = 1e-6); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 5.0) + .collect(); + let mut a = RoofingFilter::new(10, 48).unwrap(); + let mut b = RoofingFilter::new(10, 48).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut rf = RoofingFilter::new(10, 48).unwrap(); + rf.batch(&(1..=100).map(f64::from).collect::>()); + let before = rf.value(); + assert!(before.is_some()); + assert_eq!(rf.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut rf = RoofingFilter::new(10, 48).unwrap(); + rf.batch(&(1..=100).map(f64::from).collect::>()); + assert!(rf.is_ready()); + rf.reset(); + assert!(!rf.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/sine_wave.rs b/crates/wickra-core/src/indicators/sine_wave.rs new file mode 100644 index 00000000..6bb10d52 --- /dev/null +++ b/crates/wickra-core/src/indicators/sine_wave.rs @@ -0,0 +1,218 @@ +//! Ehlers Sine Wave indicator. +#![allow(clippy::manual_clamp)] + +use std::f64::consts::PI; + +use crate::indicators::hilbert_dominant_cycle::HilbertDominantCycle; +use crate::traits::Indicator; + +/// Ehlers' Sine Wave indicator (sine + leadsine). +/// +/// Implementation from *Rocket Science for Traders* (Ehlers 2001, ch. 9). Uses +/// the same Hilbert-transform machinery as [`HilbertDominantCycle`] to derive +/// the instantaneous phase, then returns `sin(phase)` and the 45° lead +/// `sin(phase + 45°)`. The two lines cross deep in trends but oscillate +/// rapidly during cycles, providing a visual lead/lag signal. +/// +/// Only the primary `sine` line is exposed as the scalar output to match the +/// crate's standard scalar-indicator surface; the lead is accessible via the +/// [`SineWave::lead`] accessor after each update. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SineWave}; +/// +/// let mut sw = SineWave::new(); +/// let mut last = None; +/// for i in 0..200 { +/// last = sw.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct SineWave { + cycle: HilbertDominantCycle, + smooth_buf: Vec, + detrender_buf: Vec, + last_phase: f64, + last_sine: Option, + last_lead: f64, + count: usize, +} + +impl SineWave { + /// Construct a new Sine Wave indicator. + pub fn new() -> Self { + Self::default() + } + + /// Most recent lead (45°-ahead) value. `0.0` until the indicator is ready. + pub const fn lead(&self) -> f64 { + self.last_lead + } + + /// Current sine value if available. + pub const fn value(&self) -> Option { + self.last_sine + } + + fn push_front(buf: &mut Vec, v: f64, cap: usize) { + buf.insert(0, v); + if buf.len() > cap { + buf.truncate(cap); + } + } +} + +impl Indicator for SineWave { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_sine; + } + self.count += 1; + // Drive the dominant-cycle estimator first; its smoothing state is + // independent from ours so the two share input but not buffers. + let _ = self.cycle.update(input); + + Self::push_front(&mut self.smooth_buf, input, 7); + if self.smooth_buf.len() < 4 { + return None; + } + let smooth = (4.0 * self.smooth_buf[0] + + 3.0 * self.smooth_buf[1] + + 2.0 * self.smooth_buf[2] + + self.smooth_buf[3]) + / 10.0; + if self.smooth_buf.len() < 7 { + return None; + } + let period = self.cycle.value().unwrap_or(15.0).max(6.0).min(50.0); + let adj = 0.075 * period + 0.54; + let s0 = smooth; + let s2 = self.smooth_buf[2]; + let s4 = self.smooth_buf[4]; + let s6 = self.smooth_buf[6]; + let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj; + Self::push_front(&mut self.detrender_buf, detrender, 7); + if self.detrender_buf.len() < 7 { + return None; + } + let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2] + - 0.5769 * self.detrender_buf[4] + - 0.0962 * self.detrender_buf[6]) + * adj; + let i1 = self.detrender_buf[3]; + let phase = if i1.abs() > f64::EPSILON { + (q1 / i1).atan() + } else { + self.last_phase + }; + self.last_phase = phase; + let sine = phase.sin(); + let lead = (phase + PI / 4.0).sin(); + + if self.count < 50 { + return None; + } + self.last_sine = Some(sine); + self.last_lead = lead; + Some(sine) + } + + fn reset(&mut self) { + self.cycle.reset(); + self.smooth_buf.clear(); + self.detrender_buf.clear(); + self.last_phase = 0.0; + self.last_sine = None; + self.last_lead = 0.0; + self.count = 0; + } + + fn warmup_period(&self) -> usize { + 50 + } + + fn is_ready(&self) -> bool { + self.last_sine.is_some() + } + + fn name(&self) -> &'static str { + "SineWave" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn accessors_and_metadata() { + let mut sw = SineWave::new(); + assert_eq!(sw.warmup_period(), 50); + assert_eq!(sw.name(), "SineWave"); + assert!(!sw.is_ready()); + assert!(sw.value().is_none()); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + sw.batch(&prices); + assert!(sw.is_ready()); + assert!(sw.value().is_some()); + } + + #[test] + fn output_bounded() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 5.0) + .collect(); + let mut sw = SineWave::new(); + for v in sw.batch(&prices).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v), "sine out of bounds: {v}"); + } + // Lead value also bounded after warmup. + assert!(sw.lead() >= -1.0 && sw.lead() <= 1.0); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut a = SineWave::new(); + let mut b = SineWave::new(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut sw = SineWave::new(); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + sw.batch(&prices); + let before = sw.value(); + assert!(before.is_some()); + assert_eq!(sw.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut sw = SineWave::new(); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + sw.batch(&prices); + assert!(sw.is_ready()); + sw.reset(); + assert!(!sw.is_ready()); + assert!(sw.value().is_none()); + } +} diff --git a/crates/wickra-core/src/indicators/super_smoother.rs b/crates/wickra-core/src/indicators/super_smoother.rs new file mode 100644 index 00000000..9e4071cd --- /dev/null +++ b/crates/wickra-core/src/indicators/super_smoother.rs @@ -0,0 +1,217 @@ +//! Ehlers SuperSmoother filter. +#![allow(clippy::doc_markdown)] + +use std::f64::consts::PI; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' 2-pole Butterworth-style "SuperSmoother" lowpass filter. +/// +/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 3). For a given +/// critical period `period`, the filter coefficients are: +/// +/// ```text +/// a1 = exp(-sqrt(2) * pi / period) +/// b1 = 2 * a1 * cos(sqrt(2) * pi / period) +/// c2 = b1 +/// c3 = -a1 * a1 +/// c1 = 1 - c2 - c3 +/// y[t] = c1 * (x[t] + x[t-1]) / 2 + c2 * y[t-1] + c3 * y[t-2] +/// ``` +/// +/// The implementation needs two prior inputs and two prior outputs to begin +/// running; until then it returns the input itself (a common Ehlers initial +/// condition), which lets downstream filters warm up without long delays. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SuperSmoother}; +/// +/// let mut ss = SuperSmoother::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = ss.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct SuperSmoother { + period: usize, + c1: f64, + c2: f64, + c3: f64, + prev_input: Option, + prev_output_1: Option, + prev_output_2: Option, + count: usize, +} + +impl SuperSmoother { + /// Construct a new SuperSmoother with the given critical period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let arg = std::f64::consts::SQRT_2 * PI / period as f64; + let a1 = (-arg).exp(); + let b1 = 2.0 * a1 * arg.cos(); + let c2 = b1; + let c3 = -a1 * a1; + let c1 = 1.0 - c2 - c3; + Ok(Self { + period, + c1, + c2, + c3, + prev_input: None, + prev_output_1: None, + prev_output_2: None, + count: 0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Filter coefficients `(c1, c2, c3)`. + pub const fn coefficients(&self) -> (f64, f64, f64) { + (self.c1, self.c2, self.c3) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.prev_output_1 + } +} + +impl Indicator for SuperSmoother { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.prev_output_1; + } + self.count += 1; + let output = match (self.prev_input, self.prev_output_1, self.prev_output_2) { + (Some(p_in), Some(y1), Some(y2)) => { + let avg = 0.5 * (input + p_in); + self.c1 * avg + self.c2 * y1 + self.c3 * y2 + } + _ => input, + }; + self.prev_output_2 = self.prev_output_1; + self.prev_output_1 = Some(output); + self.prev_input = Some(input); + Some(output) + } + + fn reset(&mut self) { + self.prev_input = None; + self.prev_output_1 = None; + self.prev_output_2 = None; + self.count = 0; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.prev_output_1.is_some() + } + + fn name(&self) -> &'static str { + "SuperSmoother" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(SuperSmoother::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut ss = SuperSmoother::new(10).unwrap(); + assert_eq!(ss.period(), 10); + assert_eq!(ss.name(), "SuperSmoother"); + assert_eq!(ss.warmup_period(), 1); + let (c1, c2, c3) = ss.coefficients(); + // Coefficients sum to 1 by construction (steady-state gain == 1). + assert_relative_eq!(c1 + c2 + c3, 1.0, epsilon = 1e-12); + assert!(ss.value().is_none()); + ss.update(42.0); + assert!(ss.value().is_some()); + assert!(ss.is_ready()); + } + + #[test] + fn first_output_equals_input_then_filters() { + let mut ss = SuperSmoother::new(10).unwrap(); + // Initial condition: first two outputs equal their inputs. + assert_eq!(ss.update(100.0), Some(100.0)); + assert_eq!(ss.update(101.0), Some(101.0)); + let third = ss.update(102.0).unwrap(); + // From step 3 onward, the recursive filter activates and the result + // is no longer the raw input. + assert!((third - 102.0).abs() < 5.0); + } + + #[test] + fn constant_series_converges_to_constant() { + // Steady-state gain is 1 (c1 + c2 + c3 = 1), so a flat input yields a + // flat output after warmup. + let mut ss = SuperSmoother::new(20).unwrap(); + let out = ss.batch(&[50.0_f64; 200]); + for x in out.iter().skip(50).flatten() { + assert_relative_eq!(*x, 50.0, epsilon = 1e-9); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = SuperSmoother::new(15).unwrap(); + let mut b = SuperSmoother::new(15).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut ss = SuperSmoother::new(10).unwrap(); + ss.batch(&(1..=20).map(f64::from).collect::>()); + let before = ss.value(); + assert!(before.is_some()); + assert_eq!(ss.update(f64::NAN), before); + assert_eq!(ss.update(f64::INFINITY), before); + } + + #[test] + fn reset_clears_state() { + let mut ss = SuperSmoother::new(10).unwrap(); + ss.batch(&(1..=40).map(f64::from).collect::>()); + assert!(ss.is_ready()); + ss.reset(); + assert!(!ss.is_ready()); + assert_eq!(ss.update(50.0), Some(50.0)); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 421f30e3..1809c5a1 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -44,35 +44,37 @@ pub mod indicators; pub use error::{Error, Result}; pub use indicators::{ - AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, AdOscillator, Adl, Adx, - AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, AnchoredVwap, Apo, Aroon, AroonOscillator, - AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AwesomeOscillator, - AwesomeOscillatorHistogram, BalanceOfPower, BollingerBands, BollingerBandwidth, - BollingerOutput, Camarilla, CamarillaPivotsOutput, Cci, Cfo, ChaikinMoneyFlow, - ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, - ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo, ConnorsRsi, - Coppock, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, Donchian, DonchianOutput, - DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, EaseOfMovement, - ElderImpulse, Ema, Evwma, FibonacciPivots, FibonacciPivotsOutput, ForceIndex, - FractalChaosBands, FractalChaosBandsOutput, Frama, GarmanKlassVolatility, HiLoActivator, - HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, Inertia, Jma, Kama, Keltner, - KeltnerOutput, Kst, KstOutput, Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, + AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, AdOscillator, AdaptiveCycle, + Adl, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, AnchoredVwap, Apo, Aroon, + AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, + AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BollingerBands, + BollingerBandwidth, BollingerOutput, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, + Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, + ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, + ClassicPivotsOutput, Cmo, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, + Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, Donchian, DonchianOutput, DonchianStop, + DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, EaseOfMovement, + EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Evwma, Fama, FibonacciPivots, + FibonacciPivotsOutput, FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, + Frama, GarmanKlassVolatility, HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, + HurstChannel, HurstChannelOutput, Inertia, InstantaneousTrendline, InverseFisherTransform, Jma, + Kama, Keltner, KeltnerOutput, Kst, KstOutput, Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput, - MacdIndicator, MacdOutput, MarketFacilitationIndex, MassIndex, McGinleyDynamic, MedianPrice, - Mfi, Mom, Natr, Nvi, Obv, ParkinsonVolatility, PercentB, PercentageTrailingStop, Pgo, Pmo, Ppo, - Psar, Pvi, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, Rsi, Rvi, - RviVolatility, Rwi, RwiOutput, Sma, Smi, Smma, StandardErrorBands, StandardErrorBandsOutput, - StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic, - StochasticOutput, SuperTrend, SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker, - TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, - TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential, - TdSequentialOutput, TdSetup, Tema, Tii, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze, - TtmSqueezeOutput, TypicalPrice, UlcerIndex, UltimateOscillator, VerticalHorizontalFilter, - Vidya, VoltyStop, VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, - VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose, - WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, - YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, - Zlema, T3, + MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, MassIndex, + McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Nvi, Obv, ParkinsonVolatility, PercentB, + PercentageTrailingStop, Pgo, Pmo, Ppo, Psar, Pvi, RenkoTrailingStop, Roc, + RogersSatchellVolatility, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, + SineWave, Sma, Smi, Smma, StandardErrorBands, StandardErrorBandsOutput, StarcBands, + StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic, StochasticOutput, + SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker, TdDifferential, + TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, + TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, Tii, Trima, + Trix, TrueRange, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex, + UltimateOscillator, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator, + VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, + Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals, WilliamsFractalsOutput, + WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, + ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, 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 632ec2f7..5d258aec 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -19,13 +19,16 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use std::hint::black_box; use wickra::{ - AccelerationBands, AdOscillator, Adxr, Alma, AnchoredVwap, Atr, AtrBands, BatchExt, - BollingerBands, Camarilla, Candle, ClassicPivots, DemandIndex, DemarkPivots, DonchianStop, - DoubleBollinger, Ema, FibonacciPivots, FractalChaosBands, Frama, GarmanKlassVolatility, - HiLoActivator, HurstChannel, Indicator, Jma, Kst, Kvo, LinRegChannel, MaEnvelope, - MacdIndicator, MarketFacilitationIndex, McGinleyDynamic, Nvi, Obv, ParkinsonVolatility, - PercentageTrailingStop, Pgo, Pvi, RenkoTrailingStop, RogersSatchellVolatility, Rsi, Rvi, - RviVolatility, Rwi, Sma, StandardErrorBands, StarcBands, StepTrailingStop, Stochastic, TdCombo, + AccelerationBands, AdOscillator, AdaptiveCycle, Adxr, Alma, AnchoredVwap, Atr, AtrBands, + BatchExt, BollingerBands, Camarilla, Candle, CenterOfGravity, ClassicPivots, CyberneticCycle, + Decycler, DecyclerOscillator, DemandIndex, DemarkPivots, DonchianStop, DoubleBollinger, + EhlersStochastic, Ema, EmpiricalModeDecomposition, Fama, FibonacciPivots, FisherTransform, + FractalChaosBands, Frama, GarmanKlassVolatility, HiLoActivator, HilbertDominantCycle, + HurstChannel, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, Kst, Kvo, + LinRegChannel, MaEnvelope, MacdIndicator, Mama, MarketFacilitationIndex, McGinleyDynamic, Nvi, + Obv, ParkinsonVolatility, PercentageTrailingStop, Pgo, Pvi, RenkoTrailingStop, + RogersSatchellVolatility, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, SineWave, Sma, + StandardErrorBands, StarcBands, StepTrailingStop, Stochastic, SuperSmoother, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, Tii, Tsv, TtmSqueeze, Vidya, VoltyStop, VolumeOscillator, VwapStdDevBands, Vzo, WaveTrend, WilliamsFractals, Wma, WoodiePivots, YangZhangVolatility, @@ -185,6 +188,67 @@ fn benches(c: &mut Criterion) { bench_candle_input(c, "stochastic", &candles, Stochastic::classic); bench_candle_input(c, "obv", &candles, Obv::new); + // Family 10 — Ehlers / Cycle scalar benchmarks. + bench_scalar(c, "super_smoother", &closes, || { + SuperSmoother::new(10).unwrap() + }); + bench_scalar(c, "fisher_transform", &closes, || { + FisherTransform::new(10).unwrap() + }); + bench_scalar(c, "inverse_fisher_transform", &closes, || { + InverseFisherTransform::new(1.0).unwrap() + }); + bench_scalar(c, "decycler", &closes, || Decycler::new(20).unwrap()); + bench_scalar(c, "decycler_oscillator", &closes, || { + DecyclerOscillator::new(10, 30).unwrap() + }); + bench_scalar(c, "roofing_filter", &closes, || { + RoofingFilter::new(10, 48).unwrap() + }); + bench_scalar(c, "center_of_gravity", &closes, || { + CenterOfGravity::new(10).unwrap() + }); + bench_scalar(c, "cybernetic_cycle", &closes, || { + CyberneticCycle::new(10).unwrap() + }); + bench_scalar(c, "instantaneous_trendline", &closes, || { + InstantaneousTrendline::new(20).unwrap() + }); + bench_scalar(c, "ehlers_stochastic", &closes, || { + EhlersStochastic::new(20).unwrap() + }); + bench_scalar(c, "empirical_mode_decomposition", &closes, || { + EmpiricalModeDecomposition::new(20, 0.5).unwrap() + }); + bench_scalar( + c, + "hilbert_dominant_cycle", + &closes, + HilbertDominantCycle::new, + ); + bench_scalar(c, "adaptive_cycle", &closes, AdaptiveCycle::new); + bench_scalar(c, "sine_wave", &closes, SineWave::new); + bench_scalar(c, "fama", &closes, || Fama::new(0.5, 0.05).unwrap()); + + // MAMA: multi-output, mirrored on macd's streaming-only bench style. + { + let mut group = c.benchmark_group("mama"); + for &n in SIZES { + let n = n.min(closes.len()); + let series = &closes[..n]; + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, prices| { + b.iter(|| { + let mut ind = Mama::classic(); + for p in prices { + black_box(ind.update(*p)); + } + }); + }); + } + group.finish(); + } + // --- Family 11: DeMark --- bench_candle_input(c, "td_setup", &candles, TdSetup::classic); bench_candle_input(c, "td_sequential", &candles, TdSequential::classic); diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index 02752f94..175ebf7f 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -15,13 +15,16 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - Alma, Apo, BatchExt, BollingerBands, Cfo, Cmo, ConnorsRsi, Coppock, Dema, DoubleBollinger, Dpo, - ElderImpulse, Ema, Frama, HistoricalVolatility, Hma, Indicator, Jma, Kama, Kst, LaguerreRsi, - LinRegAngle, LinRegChannel, LinRegSlope, LinearRegression, MaEnvelope, MacdIndicator, - McGinleyDynamic, Mom, PercentageTrailingStop, Pmo, Ppo, RenkoTrailingStop, Roc, Rsi, - RviVolatility, Sma, Smma, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, T3, Tema, - Tii, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya, Wma, ZScore, ZeroLagMacd, - Zlema, + AdaptiveCycle, Alma, Apo, BatchExt, BollingerBands, CenterOfGravity, Cfo, Cmo, ConnorsRsi, + Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DoubleBollinger, Dpo, + EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Fama, FisherTransform, + Frama, HilbertDominantCycle, HistoricalVolatility, Hma, Indicator, InstantaneousTrendline, + InverseFisherTransform, Jma, Kama, Kst, LaguerreRsi, LinRegAngle, LinRegChannel, + LinRegSlope, LinearRegression, MaEnvelope, MacdIndicator, Mama, McGinleyDynamic, Mom, + PercentageTrailingStop, Pmo, Ppo, RenkoTrailingStop, Roc, RoofingFilter, Rsi, RviVolatility, + SineWave, Sma, Smma, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, + SuperSmoother, T3, Tema, Tii, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya, + Wma, ZScore, ZeroLagMacd, Zlema, }; /// Drive a single streaming + batch run through one scalar indicator. Marked @@ -112,8 +115,26 @@ fuzz_target!(|data: Vec| { drive(|| StepTrailingStop::new(1.0).unwrap(), &data); drive(|| RenkoTrailingStop::new(1.0).unwrap(), &data); - // MACD and Bollinger Bands have non-`f64` outputs, so they cannot use the - // generic `drive` helper above. Streaming + batch are still both exercised. + // Family 10 — Ehlers / Cycle scalar indicators. + drive(|| SuperSmoother::new(10).unwrap(), &data); + drive(|| FisherTransform::new(10).unwrap(), &data); + drive(|| InverseFisherTransform::new(1.0).unwrap(), &data); + drive(|| Decycler::new(20).unwrap(), &data); + drive(|| DecyclerOscillator::new(10, 30).unwrap(), &data); + drive(|| RoofingFilter::new(10, 48).unwrap(), &data); + drive(|| CenterOfGravity::new(10).unwrap(), &data); + drive(|| CyberneticCycle::new(10).unwrap(), &data); + drive(|| InstantaneousTrendline::new(20).unwrap(), &data); + drive(|| EhlersStochastic::new(20).unwrap(), &data); + drive(|| EmpiricalModeDecomposition::new(20, 0.5).unwrap(), &data); + drive(HilbertDominantCycle::new, &data); + drive(AdaptiveCycle::new, &data); + drive(SineWave::new, &data); + drive(|| Fama::new(0.5, 0.05).unwrap(), &data); + + // MACD, Bollinger Bands and MAMA have non-`f64` outputs, so they cannot + // use the generic `drive` helper above. Streaming + batch are still both + // exercised. { let mut macd = MacdIndicator::new(12, 26, 9).unwrap(); for &x in &data { @@ -128,6 +149,13 @@ fuzz_target!(|data: Vec| { } let _ = BollingerBands::new(20, 2.0).unwrap().batch(&data); } + { + let mut mama = Mama::new(0.5, 0.05).unwrap(); + for &x in &data { + let _ = mama.update(x); + } + let _ = Mama::new(0.5, 0.05).unwrap().batch(&data); + } // --- Family 05: scalar-input band/channel indicators (multi-output) --- {