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:
@@ -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",
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ---------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user