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
+524
View File
@@ -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<Self> {
Ok(Self {
inner: <$rust_ty>::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
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<f64> {
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<Self> {
Ok(Self {
inner: wc::InverseFisherTransform::new(scale).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
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<f64> {
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<Self> {
Ok(Self {
inner: wc::DecyclerOscillator::new(fast, slow).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
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<Self> {
Ok(Self {
inner: wc::RoofingFilter::new(lp_period, hp_period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
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<Self> {
Ok(Self {
inner: wc::EmpiricalModeDecomposition::new(period, fraction).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
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<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
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<f64> {
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<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
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<f64> {
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<Self> {
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<Bound<'py, PyArray2<f64>>> {
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<Self> {
Ok(Self {
inner: wc::Fama::new(fast_limit, slow_limit).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
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<f64> {
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::<PyTdDifferential>()?;
m.add_class::<PyTdOpen>()?;
m.add_class::<PyTdRiskLevel>()?;
// Family 10 — Ehlers / Cycle
m.add_class::<PySuperSmoother>()?;
m.add_class::<PyFisherTransform>()?;
m.add_class::<PyInverseFisherTransform>()?;
m.add_class::<PyDecycler>()?;
m.add_class::<PyDecyclerOscillator>()?;
m.add_class::<PyRoofingFilter>()?;
m.add_class::<PyCenterOfGravity>()?;
m.add_class::<PyCyberneticCycle>()?;
m.add_class::<PyInstantaneousTrendline>()?;
m.add_class::<PyEhlersStochastic>()?;
m.add_class::<PyEmd>()?;
m.add_class::<PyHilbertDominantCycle>()?;
m.add_class::<PyAdaptiveCycle>()?;
m.add_class::<PySineWave>()?;
m.add_class::<PyMama>()?;
m.add_class::<PyFama>()?;
Ok(())
}