feat(family-10): add 16 Ehlers / Cycle (DSP) indicators (#49)

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.
This commit is contained in:
kingchenc
2026-05-25 22:14:27 +02:00
committed by GitHub
parent 4f9ed34884
commit 7a18a26daf
34 changed files with 4947 additions and 46 deletions
@@ -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)) {
+17 -1
View File
@@ -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
+357
View File
@@ -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<Self> {
Ok(Self {
inner: wc::InverseFisherTransform::new(scale).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
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<Self> {
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<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
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<Self> {
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<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
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<Self> {
Ok(Self {
inner: wc::EmpiricalModeDecomposition::new(period as usize, fraction)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
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<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
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<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
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<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
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<Self> {
Ok(Self {
inner: wc::Mama::new(fast_limit, slow_limit).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<MamaValue> {
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<f64>) -> Vec<f64> {
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<Self> {
Ok(Self {
inner: wc::Fama::new(fast_limit, slow_limit).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
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
}
}