Deepen Moving Averages family with seven additions (#177)

Deepens the **Moving Averages** family with seven widely-used variants
(396 → 403 indicators), the first batch of Part B (family deepening).

All are scalar `f64 → f64`:

| Indicator | Binding | Notes |
|-----------|---------|-------|
| `SineWeightedMa` | `SWMA` | symmetric half-cycle sine-weighted window |
| `GeometricMa` | `GMA` | rolling geometric mean (log-space average) |
| `Ehma` | `EHMA` | exponential Hull MA (Hull construction over EMAs) |
| `MedianMa` | `MedianMA` | rolling median, robust to single outliers |
| `AdaptiveLaguerreFilter` | `AdaptiveLaguerre` | Ehlers' adaptive Laguerre filter (median-of-normalised-error γ) |
| `GeneralizedDema` | `GD` | Tillson's volume-factor double EMA; `v=1` is DEMA, `v=0` is EMA |
| `HoltWinters` | `HoltWinters` | Holt's linear double exponential smoothing (level + trend) |

LSMA was dropped from the planned set: it already ships as `LinearRegression`
(TA-Lib `LINEARREG`, the rolling least-squares endpoint).

The five single-period filters use the generated scalar macro bindings;
`GeneralizedDema` (period, v) and `HoltWinters` (alpha, beta) use hand-written
node/python bindings with the typed wasm macro (precedent `T3` / `Alma`).

Full coverage: core modules with per-branch unit tests (100% intent), mod/lib
catalogue, FAMILIES group + assert, README + docs counters, CHANGELOG, all three
bindings (regenerated `index.d.ts` / `index.js`), fuzz drivers, and the
python/node test registries.

Local verification: `cargo test -p wickra-core` (lib 3255 + doc 361),
`cargo clippy --workspace --all-targets --all-features -D warnings` clean,
node `npm run build && npm test` (478), python `pytest` (791).
This commit is contained in:
kingchenc
2026-06-04 13:44:51 +02:00
committed by GitHub
parent 8dc7158912
commit b228a70d7d
21 changed files with 2492 additions and 55 deletions
@@ -28,6 +28,13 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
HoltWinters: () => new wickra.HoltWinters(0.2, 0.1),
GD: () => new wickra.GD(5, 0.7),
AdaptiveLaguerre: () => new wickra.AdaptiveLaguerre(13),
MedianMA: () => new wickra.MedianMA(14),
EHMA: () => new wickra.EHMA(9),
GMA: () => new wickra.GMA(14),
SWMA: () => new wickra.SWMA(14),
Expectancy: () => new wickra.Expectancy(20),
WinRate: () => new wickra.WinRate(20),
RegimeLabel: () => new wickra.RegimeLabel(5, 20),
+63
View File
@@ -872,6 +872,51 @@ export declare class Expectancy {
isReady(): boolean
warmupPeriod(): number
}
export type SineWeightedMaNode = SWMA
export declare class SWMA {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type GeometricMaNode = GMA
export declare class GMA {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type EhmaNode = EHMA
export declare class EHMA {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MedianMaNode = MedianMA
export declare class MedianMA {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AdaptiveLaguerreFilterNode = AdaptiveLaguerre
export declare class AdaptiveLaguerre {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type JumpIndicatorNode = JumpIndicator
export declare class JumpIndicator {
constructor(period: number, threshold: number)
@@ -1677,6 +1722,24 @@ export declare class T3 {
isReady(): boolean
warmupPeriod(): number
}
export type GeneralizedDemaNode = GD
export declare class GD {
constructor(period: number, v: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type HoltWintersNode = HoltWinters
export declare class HoltWinters {
constructor(alpha: number, beta: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TsiNode = TSI
export declare class TSI {
constructor(long: number, short: number)
File diff suppressed because one or more lines are too long
+83
View File
@@ -197,6 +197,15 @@ node_scalar_indicator!(
node_scalar_indicator!(TrendLabelNode, "TrendLabel", wc::TrendLabel);
node_scalar_indicator!(WinRateNode, "WinRate", wc::WinRate);
node_scalar_indicator!(ExpectancyNode, "Expectancy", wc::Expectancy);
node_scalar_indicator!(SineWeightedMaNode, "SWMA", wc::SineWeightedMa);
node_scalar_indicator!(GeometricMaNode, "GMA", wc::GeometricMa);
node_scalar_indicator!(EhmaNode, "EHMA", wc::Ehma);
node_scalar_indicator!(MedianMaNode, "MedianMA", wc::MedianMa);
node_scalar_indicator!(
AdaptiveLaguerreFilterNode,
"AdaptiveLaguerre",
wc::AdaptiveLaguerreFilter
);
#[napi(js_name = "JumpIndicator")]
pub struct JumpIndicatorNode {
inner: wc::JumpIndicator,
@@ -3794,6 +3803,80 @@ impl T3Node {
}
}
// ============================== GD ==============================
#[napi(js_name = "GD")]
pub struct GeneralizedDemaNode {
inner: wc::GeneralizedDema,
}
#[napi]
impl GeneralizedDemaNode {
#[napi(constructor)]
pub fn new(period: u32, v: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::GeneralizedDema::new(period as usize, v).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
}
}
// ============================== HoltWinters ==============================
#[napi(js_name = "HoltWinters")]
pub struct HoltWintersNode {
inner: wc::HoltWinters,
}
#[napi]
impl HoltWintersNode {
#[napi(constructor)]
pub fn new(alpha: f64, beta: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::HoltWinters::new(alpha, beta).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
}
}
// ============================== TSI ==============================
#[napi(js_name = "TSI")]
+14
View File
@@ -25,6 +25,13 @@ from __future__ import annotations
from ._wickra import (
__version__,
HoltWinters,
GD,
AdaptiveLaguerre,
MedianMA,
EHMA,
GMA,
SWMA,
Expectancy,
WinRate,
RegimeLabel,
@@ -449,6 +456,13 @@ from ._wickra import (
)
__all__ = [
"HoltWinters",
"GD",
"AdaptiveLaguerre",
"MedianMA",
"EHMA",
"GMA",
"SWMA",
"Expectancy",
"WinRate",
"RegimeLabel",
+375
View File
@@ -2354,6 +2354,250 @@ impl PyExpectancy {
}
}
// ============================== SineWeightedMa ==============================
#[pyclass(name = "SWMA", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PySineWeightedMa {
inner: wc::SineWeightedMa,
}
#[pymethods]
impl PySineWeightedMa {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::SineWeightedMa::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 s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
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!("SWMA(period={})", self.inner.period())
}
}
// ============================== GeometricMa ==============================
#[pyclass(name = "GMA", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyGeometricMa {
inner: wc::GeometricMa,
}
#[pymethods]
impl PyGeometricMa {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::GeometricMa::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 s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
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!("GMA(period={})", self.inner.period())
}
}
// ============================== Ehma ==============================
#[pyclass(name = "EHMA", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyEhma {
inner: wc::Ehma,
}
#[pymethods]
impl PyEhma {
#[new]
#[pyo3(signature = (period=9))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Ehma::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 s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
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!("EHMA(period={})", self.inner.period())
}
}
// ============================== MedianMa ==============================
#[pyclass(name = "MedianMA", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyMedianMa {
inner: wc::MedianMa,
}
#[pymethods]
impl PyMedianMa {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::MedianMa::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 s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
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!("MedianMA(period={})", self.inner.period())
}
}
// ============================== AdaptiveLaguerreFilter ==============================
#[pyclass(
name = "AdaptiveLaguerre",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyAdaptiveLaguerreFilter {
inner: wc::AdaptiveLaguerreFilter,
}
#[pymethods]
impl PyAdaptiveLaguerreFilter {
#[new]
#[pyo3(signature = (period=13))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::AdaptiveLaguerreFilter::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 s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
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!("AdaptiveLaguerre(period={})", self.inner.period())
}
}
// ============================== Stochastic ==============================
#[pyclass(name = "Stochastic", module = "wickra._wickra", skip_from_py_object)]
@@ -6197,6 +6441,130 @@ impl PyT3 {
}
}
// ============================== GD ==============================
#[pyclass(name = "GD", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyGeneralizedDema {
inner: wc::GeneralizedDema,
}
#[pymethods]
impl PyGeneralizedDema {
#[new]
#[pyo3(signature = (period, v=0.7))]
fn new(period: usize, v: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::GeneralizedDema::new(period, v).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 volume_factor(&self) -> f64 {
self.inner.volume_factor()
}
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!(
"GD(period={}, v={})",
self.inner.period(),
self.inner.volume_factor()
)
}
}
// ============================== HoltWinters ==============================
#[pyclass(name = "HoltWinters", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyHoltWinters {
inner: wc::HoltWinters,
}
#[pymethods]
impl PyHoltWinters {
#[new]
#[pyo3(signature = (alpha=0.2, beta=0.1))]
fn new(alpha: f64, beta: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::HoltWinters::new(alpha, beta).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 alpha(&self) -> f64 {
self.inner.alpha()
}
#[getter]
fn beta(&self) -> f64 {
self.inner.beta()
}
#[getter]
fn level(&self) -> Option<f64> {
self.inner.level()
}
#[getter]
fn trend(&self) -> Option<f64> {
self.inner.trend()
}
#[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!(
"HoltWinters(alpha={}, beta={})",
self.inner.alpha(),
self.inner.beta()
)
}
}
// ============================== VWMA ==============================
#[pyclass(name = "VWMA", module = "wickra._wickra", skip_from_py_object)]
@@ -19667,6 +20035,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyTrima>()?;
m.add_class::<PyZlema>()?;
m.add_class::<PyT3>()?;
m.add_class::<PyGeneralizedDema>()?;
m.add_class::<PyHoltWinters>()?;
m.add_class::<PyVwma>()?;
m.add_class::<PyMom>()?;
m.add_class::<PyCmo>()?;
@@ -20031,5 +20401,10 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyRegimeLabel>()?;
m.add_class::<PyWinRate>()?;
m.add_class::<PyExpectancy>()?;
m.add_class::<PySineWeightedMa>()?;
m.add_class::<PyGeometricMa>()?;
m.add_class::<PyEhma>()?;
m.add_class::<PyMedianMa>()?;
m.add_class::<PyAdaptiveLaguerreFilter>()?;
Ok(())
}
@@ -45,6 +45,13 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.HoltWinters, (0.2, 0.1)),
(ta.GD, (5, 0.7)),
(ta.AdaptiveLaguerre, (13,)),
(ta.MedianMA, (14,)),
(ta.EHMA, (9,)),
(ta.GMA, (14,)),
(ta.SWMA, (14,)),
(ta.Expectancy, (20,)),
(ta.WinRate, (20,)),
(ta.RegimeLabel, (5, 20)),
+7
View File
@@ -10213,6 +10213,13 @@ wasm_scalar_indicator!(WasmJumpIndicator, "JumpIndicator", wc::JumpIndicator, pe
wasm_scalar_indicator!(WasmRegimeLabel, "RegimeLabel", wc::RegimeLabel, vol_period: usize, lookback: usize);
wasm_scalar_indicator!(WasmWinRate, "WinRate", wc::WinRate, period: usize);
wasm_scalar_indicator!(WasmExpectancy, "Expectancy", wc::Expectancy, period: usize);
wasm_scalar_indicator!(WasmSineWeightedMa, "SWMA", wc::SineWeightedMa, period: usize);
wasm_scalar_indicator!(WasmGeometricMa, "GMA", wc::GeometricMa, period: usize);
wasm_scalar_indicator!(WasmEhma, "EHMA", wc::Ehma, period: usize);
wasm_scalar_indicator!(WasmMedianMa, "MedianMA", wc::MedianMa, period: usize);
wasm_scalar_indicator!(WasmAdaptiveLaguerreFilter, "AdaptiveLaguerre", wc::AdaptiveLaguerreFilter, period: usize);
wasm_scalar_indicator!(WasmGeneralizedDema, "GD", wc::GeneralizedDema, period: usize, v: f64);
wasm_scalar_indicator!(WasmHoltWinters, "HoltWinters", wc::HoltWinters, alpha: f64, beta: f64);
// --- DrawdownDuration: u32 output, no constructor args ---