diff --git a/bindings/node/index.js b/bindings/node/index.js index 122c7674..b7af04e7 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -322,6 +322,8 @@ module.exports.TEMA = TEMA module.exports.HMA = HMA module.exports.ROC = ROC module.exports.TRIX = TRIX +module.exports.SMMA = SMMA +module.exports.TRIMA = TRIMA module.exports.MACD = MACD module.exports.BollingerBands = BollingerBands module.exports.ATR = ATR diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 39ac8a45..e208526a 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -102,6 +102,8 @@ node_scalar_indicator!(TemaNode, "TEMA", wc::Tema); node_scalar_indicator!(HmaNode, "HMA", wc::Hma); node_scalar_indicator!(RocNode, "ROC", wc::Roc); node_scalar_indicator!(TrixNode, "TRIX", wc::Trix); +node_scalar_indicator!(SmmaNode, "SMMA", wc::Smma); +node_scalar_indicator!(TrimaNode, "TRIMA", wc::Trima); // ============================== MACD ============================== diff --git a/bindings/python/python/wickra/__init__.pyi b/bindings/python/python/wickra/__init__.pyi index 87d8611a..0f8aeec6 100644 --- a/bindings/python/python/wickra/__init__.pyi +++ b/bindings/python/python/wickra/__init__.pyi @@ -52,6 +52,30 @@ class WMA: @property def value(self) -> Optional[float]: ... +class SMMA: + def __init__(self, period: int) -> None: ... + def update(self, value: float) -> Optional[float]: ... + def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def period(self) -> int: ... + @property + def value(self) -> Optional[float]: ... + +class TRIMA: + def __init__(self, period: int) -> None: ... + def update(self, value: float) -> Optional[float]: ... + def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def period(self) -> int: ... + @property + def value(self) -> Optional[float]: ... + class RSI: def __init__(self, period: int = 14) -> None: ... def update(self, value: float) -> Optional[float]: ... diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 5a3d0453..b5328d44 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1519,6 +1519,108 @@ impl PyAroon { } } +// ============================== SMMA ============================== + +#[pyclass(name = "SMMA", module = "wickra._wickra")] +#[derive(Clone)] +struct PySmma { + inner: wc::Smma, +} + +#[pymethods] +impl PySmma { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Smma::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn value(&self) -> Option { + 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!("SMMA(period={})", self.inner.period()) + } +} + +// ============================== TRIMA ============================== + +#[pyclass(name = "TRIMA", module = "wickra._wickra")] +#[derive(Clone)] +struct PyTrima { + inner: wc::Trima, +} + +#[pymethods] +impl PyTrima { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Trima::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn value(&self) -> Option { + 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!("TRIMA(period={})", self.inner.period()) + } +} + // ============================== Module ============================== #[pymodule] @@ -1549,5 +1651,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index b5bed4ec..f7afcbb8 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -75,6 +75,8 @@ wasm_scalar_indicator!(WasmTema, "TEMA", wc::Tema, period: usize); wasm_scalar_indicator!(WasmHma, "HMA", wc::Hma, period: usize); wasm_scalar_indicator!(WasmRoc, "ROC", wc::Roc, period: usize); wasm_scalar_indicator!(WasmTrix, "TRIX", wc::Trix, period: usize); +wasm_scalar_indicator!(WasmSmma, "SMMA", wc::Smma, period: usize); +wasm_scalar_indicator!(WasmTrima, "TRIMA", wc::Trima, period: usize); // ---------- KAMA (three params) ---------- diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 7068d49e..7e219e14 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -79,6 +79,8 @@ Rust / Python / Node examples. They are grouped by family, mirroring the - [Indicator-Tema.md](indicators/trend/Indicator-Tema.md) - [Indicator-Hma.md](indicators/trend/Indicator-Hma.md) - [Indicator-Kama.md](indicators/trend/Indicator-Kama.md) +- [Indicator-Smma.md](indicators/trend/Indicator-Smma.md) +- [Indicator-Trima.md](indicators/trend/Indicator-Trima.md) **Momentum** — measure the rate of price change rather than the level. diff --git a/docs/wiki/Indicators-Overview.md b/docs/wiki/Indicators-Overview.md index fb0641d0..720073a0 100644 --- a/docs/wiki/Indicators-Overview.md +++ b/docs/wiki/Indicators-Overview.md @@ -1,6 +1,6 @@ # Indicators Overview -Wickra ships 25 indicators, organised in source under the four classical +Wickra ships 27 indicators, organised in source under the four classical families — trend, momentum, volatility, volume — that map directly to the directory structure of `crates/wickra-core/src/indicators/`. The same family labels are used here, plus a second-level grouping that reflects how the @@ -35,6 +35,7 @@ benchmarks against fancier averages. |-----------|-----------|-------|--------|-------|----------|--------|-----------| | `Sma` | Equal-weighted rolling mean over `period` closes. | `f64` | `f64` | unbounded (price scale) | `period` (no default in core; Python defaults vary by binding) | `period` | [Indicator-Sma.md](indicators/trend/Indicator-Sma.md) | | `Wma` | Linear weights `1, 2, …, period` so the newest bar matters most. | `f64` | `f64` | unbounded (price scale) | `period` | `period` | [Indicator-Wma.md](indicators/trend/Indicator-Wma.md) | +| `Trima` | A `period`-window SMA applied twice; triangular weights centred on the middle bar. | `f64` | `f64` | unbounded (price scale) | `period` | `period` | [Indicator-Trima.md](indicators/trend/Indicator-Trima.md) | ### Exponential family @@ -46,6 +47,7 @@ you stack more EMAs, but so does responsiveness to noise. | `Ema` | EMA with `α = 2 / (period + 1)`, seeded from the SMA of the first `period` inputs. | `f64` | `f64` | unbounded (price scale) | `period` | `period` | [Indicator-Ema.md](indicators/trend/Indicator-Ema.md) | | `Dema` | Mulloy's `2·EMA − EMA(EMA)`; removes first-order EMA lag. | `f64` | `f64` | unbounded (price scale) | `period` | `2·period − 1` | [Indicator-Dema.md](indicators/trend/Indicator-Dema.md) | | `Tema` | Mulloy's `3·EMA − 3·EMA(EMA) + EMA(EMA(EMA))`; removes more lag than DEMA. | `f64` | `f64` | unbounded (price scale) | `period` | `3·period − 2` | [Indicator-Tema.md](indicators/trend/Indicator-Tema.md) | +| `Smma` | Wilder's RMA: an SMA-seeded exponential average with the slow `1/period` factor. | `f64` | `f64` | unbounded (price scale) | `period` | `period` | [Indicator-Smma.md](indicators/trend/Indicator-Smma.md) | `Trix` is also built from a triple-smoothed EMA, but it is a *momentum oscillator* — it emits the rate of change of that EMA, not a price-scale diff --git a/docs/wiki/indicators/trend/Indicator-Smma.md b/docs/wiki/indicators/trend/Indicator-Smma.md new file mode 100644 index 00000000..96d1134c --- /dev/null +++ b/docs/wiki/indicators/trend/Indicator-Smma.md @@ -0,0 +1,171 @@ +# SMMA + +> Smoothed Moving Average — Wilder's running moving average (RMA): an +> SMA-seeded exponential average with a slow `1 / period` smoothing factor. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Trend | +| Sub-category | Exponential family | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | unbounded; tracks the input price scale | +| Default parameters | `period` is required (no default in either binding) | +| Warmup period | `period` | +| Interpretation | Heavily smoothed price level; the average underlying Wilder's RSI and ATR. | + +## Formula + +``` +SMMA_period = SMA(price_1 … price_period) (seed) +SMMA_t = (SMMA_{t-1} * (period - 1) + price_t) / period (t > period) +``` + +This is algebraically an exponential moving average with smoothing factor +`alpha = 1 / period` — substantially slower than the `Ema` factor of +`2 / (period + 1)` at the same `period`. The recurrence is O(1): each +`update` touches only the previous value. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|---------|-------------|-------------| +| `period` | `usize` | none | `>= 1` | Smoothing length. `period = 0` errors with `Error::PeriodZero`. `period = 1` is a pass-through. | + +There is no Python `#[pyo3(signature = …)]` default for `SMMA`, so +`wickra.SMMA(period)` requires the period explicitly. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/smma.rs`: + +```rust +impl Indicator for Smma { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +A single `f64` close in, an `Option` out. Python maps this to +`float | None` (streaming) or a `numpy.ndarray` with `NaN` warmup rows +(batch); Node maps it to `number | null` / `Array` with `NaN` +warmup. + +## Warmup + +`Smma::new(period).warmup_period() == period`. The first `period - 1` +inputs are buffered while the seed accumulates; the `period`-th `update()` +emits the simple average of those inputs as `SMMA_period`. Every later +input applies the `(prev·(n−1)+x)/n` recurrence. + +## Edge cases + +- **Constant series.** Feeding `[7.0; n]` returns `Some(7.0)` from input + `period` onward — the recurrence is a fixed point for constants + (`constant_series_yields_the_constant` pins this). +- **NaN / infinity inputs.** The first line of `update` is + `if !input.is_finite() { return self.current; }`. Non-finite inputs are + **silently dropped** — they neither advance the seed nor perturb the + recurrence, and the previous valid value (if any) is returned. +- **Reset.** `smma.reset()` clears the seed buffer and the current value, + restarting the warmup countdown. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, Smma}; + +fn main() -> Result<(), Box> { + let mut smma = Smma::new(3)?; + let out: Vec> = smma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + println!("{:?}", out); + println!("warmup_period = {}", smma.warmup_period()); + Ok(()) +} +``` + +Output: + +``` +[None, None, Some(2.0), Some(2.6666666666666665), Some(3.4444444444444446)] +warmup_period = 3 +``` + +The third input emits the seed `(1 + 2 + 3) / 3 = 2.0`; the fourth applies +`(2.0·2 + 4) / 3 = 8/3`; the fifth `(8/3·2 + 5) / 3 = 31/9`. This matches +the `warmup_then_recurrence` test in +`crates/wickra-core/src/indicators/smma.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +smma = ta.SMMA(3) +print(smma.batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0]))) +print("warmup_period =", smma.warmup_period()) +``` + +Output: + +``` +[ nan nan 2. 2.6666667 3.4444444] +warmup_period = 3 +``` + +### Node + +```javascript +const ta = require('wickra'); +const smma = new ta.SMMA(3); +console.log(smma.batch([1, 2, 3, 4, 5])); +console.log('warmupPeriod:', smma.warmupPeriod()); +``` + +Output: + +``` +[ NaN, NaN, 2, 2.6666666666666665, 3.4444444444444446 ] +warmupPeriod: 3 +``` + +## Interpretation + +`Smma` is a very smooth, lag-heavy price level. Because its smoothing +factor is `1 / period` rather than `2 / (period + 1)`, an `Smma(n)` is +roughly as smooth as an `Ema(2n − 1)` — useful when you want maximum +noise rejection from a single line. Its main role in this library, +however, is structural: it is the exact smoothing kernel inside +[`Rsi`](../momentum/Indicator-Rsi.md) and [`Atr`](../volatility/Indicator-Atr.md), +so reaching for `Smma` directly lets you reproduce Wilder-style averages +on any series. + +## Common pitfalls + +- **Confusing it with `Ema` at the same period.** `Smma(n)` and `Ema(n)` + are *not* interchangeable — `Smma` lags far more. Match `Ema(2n − 1)` + if you need comparable smoothness. +- **Treating `period = 0` as "use a default".** `Smma::new(0)` returns + `Err(Error::PeriodZero)` in Rust and a `ValueError` in Python; pass an + explicit period. + +## References + +The smoothed moving average is J. Welles Wilder Jr.'s running average +from *New Concepts in Technical Trading Systems* (1978); it is the +averaging step in his RSI, ATR and ADX. The implementation here follows +the standard SMA-seeded formulation, matching TA-Lib's `RMA`. + +## See also + +- [Indicator-Ema.md](Indicator-Ema.md) — faster exponential average. +- [Indicator-Sma.md](Indicator-Sma.md) — the equal-weighted mean used as + the SMMA seed. +- [Indicator-Trima.md](Indicator-Trima.md) — the other F1 average. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/trend/Indicator-Trima.md b/docs/wiki/indicators/trend/Indicator-Trima.md new file mode 100644 index 00000000..6d7c1321 --- /dev/null +++ b/docs/wiki/indicators/trend/Indicator-Trima.md @@ -0,0 +1,167 @@ +# TRIMA + +> Triangular Moving Average — a simple moving average applied twice, which +> triangular-weights the window so the middle bars carry the most weight. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Trend | +| Sub-category | Simple averages | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | unbounded; tracks the input price scale | +| Default parameters | `period` is required (no default in either binding) | +| Warmup period | `period` | +| Interpretation | Very smooth price level; the triangular weighting suppresses edge bars. | + +## Formula + +`TRIMA(n)` is `SMA` stacked on `SMA`. For period `n` the two lengths are: + +``` +odd n: n1 = n2 = (n + 1) / 2 +even n: n1 = n / 2, n2 = n / 2 + 1 +TRIMA_t = SMA_{n2}( SMA_{n1}(price) )_t +``` + +Composing two equal-weight means convolves two rectangular windows, which +yields a triangular weight profile over the original `n` closes — the +centre bar gets the largest weight, the two edges the smallest. Both +stacked SMAs are O(1), so `update` is O(1) regardless of `period`. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|---------|-------------|-------------| +| `period` | `usize` | none | `>= 1` | Window length. `period = 0` errors with `Error::PeriodZero`. `period = 1` and `period = 2` degenerate to short SMAs. | + +There is no Python `#[pyo3(signature = …)]` default for `TRIMA`, so +`wickra.TRIMA(period)` requires the period explicitly. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/trima.rs`: + +```rust +impl Indicator for Trima { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +A single `f64` close in, an `Option` out. Python maps this to +`float | None` / `numpy.ndarray` (NaN warmup); Node to `number | null` / +`Array` (NaN warmup). + +## Warmup + +`Trima::new(period).warmup_period() == period`. The inner SMA emits after +`n1` inputs; the outer SMA then needs `n2 − 1` more, and `n1 + n2 − 1 = n` +for both the odd and even splits. So the first non-`None` output lands on +exactly the `period`-th `update()`. + +## Edge cases + +- **Constant series.** `[42.0; n]` returns `Some(42.0)` from input + `period` onward — both SMAs are exact for constants + (`constant_series_yields_the_constant` pins this). +- **NaN / infinity inputs.** `update` returns `self.outer.value()` for a + non-finite input *without* feeding either SMA, so the inner SMA's stale + value is never double-counted into the outer SMA. State is left + untouched. +- **Reset.** `trima.reset()` resets both inner and outer SMAs, restarting + the warmup countdown. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, Trima}; + +fn main() -> Result<(), Box> { + let mut trima = Trima::new(5)?; + let out: Vec> = trima.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); + println!("{:?}", out); + println!("warmup_period = {}", trima.warmup_period()); + Ok(()) +} +``` + +Output: + +``` +[None, None, None, None, Some(3.0), Some(4.0), Some(5.0)] +warmup_period = 5 +``` + +`TRIMA(5)` is `SMA(3)` of `SMA(3)`. `SMA(3)` of `1..=7` is +`[_, _, 2, 3, 4, 5, 6]`; `SMA(3)` of that is `[_, _, _, _, 3, 4, 5]`. This +matches the `odd_period_reference_values` test in +`crates/wickra-core/src/indicators/trima.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +trima = ta.TRIMA(5) +print(trima.batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]))) +print("warmup_period =", trima.warmup_period()) +``` + +Output: + +``` +[nan nan nan nan 3. 4. 5.] +warmup_period = 5 +``` + +### Node + +```javascript +const ta = require('wickra'); +const trima = new ta.TRIMA(5); +console.log(trima.batch([1, 2, 3, 4, 5, 6, 7])); +console.log('warmupPeriod:', trima.warmupPeriod()); +``` + +Output: + +``` +[ NaN, NaN, NaN, NaN, 3, 4, 5 ] +warmupPeriod: 5 +``` + +## Interpretation + +`Trima` is one of the smoothest single-line averages in the library: the +triangular weight profile damps the most recent bar far more than a plain +`Sma` does, so whipsaws are rare. The cost is lag — a `Trima(n)` lags +roughly like an `Sma(n/2)` doubled. Use it as a slow trend filter where a +clean, low-noise line matters more than fast reaction; prefer +[`Ema`](Indicator-Ema.md) or [`Hma`](Indicator-Hma.md) when responsiveness +matters. + +## Common pitfalls + +- **Expecting `Sma`-like lag.** Stacking two means roughly doubles the + effective lag; size the period accordingly. +- **Treating `period = 0` as "use a default".** `Trima::new(0)` returns + `Err(Error::PeriodZero)` in Rust and a `ValueError` in Python. + +## References + +The triangular moving average is a standard double-smoothed SMA; the +odd/even split used here (`n1`, `n2`) matches TA-Lib's `TRIMA`. + +## See also + +- [Indicator-Sma.md](Indicator-Sma.md) — the building block applied twice. +- [Indicator-Wma.md](Indicator-Wma.md) — linear (not triangular) weights. +- [Indicator-Smma.md](Indicator-Smma.md) — the other F1 average. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.