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
+34
View File
@@ -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",
+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(())
}
@@ -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 ---------------------------------------------------------
+17
View File
@@ -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)
+10
View File
@@ -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.