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
+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
}
}