From 2f3b5cc3be8f672175cc6529266902ad001b1c75 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Fri, 22 May 2026 20:04:13 +0200 Subject: [PATCH] F-Abschluss: wire the Python package, refresh docs and extend the test suites Finalises the F1-F12 indicator expansion (25 -> 63 indicators). - Python `wickra/__init__.py`: import and re-export all 63 indicators, grouped by family, with a matching `__all__`. The package previously exposed only the original 25 even though the compiled module and the `.pyi` stubs already carried the rest. - Docs: `Home.md` and `README.md` indicator counts and family tables updated to 63; `Indicators-Overview.md` already restructured per family in F10-F12; `Warmup-Periods.md` gains all 38 new indicators across the single- and multi-output tables (and the stale two-arg `Psar::new` example is corrected to three args); `CHANGELOG.md` `[Unreleased]` lists every new indicator by family. - Tests: `bindings/node/__tests__/indicators.test.js` covers all 63 indicators (streaming==batch plus four new reference-value checks), 80/80 green; new `bindings/python/tests/test_new_indicators.py` covers the 38 additions (streaming==batch, shapes, reference values, lifecycle), Python suite 105/105 green. - `bindings/node/index.js` regenerated by `napi build`. cargo fmt + clippy (core/wickra/data/wasm/node) clean; 454 core tests, 25 data tests, 66 doctests, 80 Node tests and 105 Python tests green; `cargo check -p wickra-wasm --tests` green. --- CHANGELOG.md | 13 + README.md | 17 +- bindings/node/__tests__/indicators.test.js | 71 +++++- bindings/node/index.js | 62 ++--- bindings/python/python/wickra/__init__.py | 144 ++++++++--- bindings/python/tests/test_new_indicators.py | 253 +++++++++++++++++++ docs/wiki/Home.md | 7 +- docs/wiki/Warmup-Periods.md | 40 ++- 8 files changed, 534 insertions(+), 73 deletions(-) create mode 100644 bindings/python/tests/test_new_indicators.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a78951a..f87174d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- 38 new technical indicators, taking the library from 25 to 63. Each is + implemented once in the Rust core and wired through the Python, Node and + WASM bindings, with reference-value tests and a dedicated wiki page: + - Trend: `Smma`, `Trima`, `Zlema`, `T3`, `Vwma`. + - Momentum: `Mom`, `Cmo`, `Tsi`, `Pmo`, `StochRsi`, `UltimateOscillator`, + `Ppo`, `Dpo`, `Coppock`, `AroonOscillator`, `Vortex`, `MassIndex`. + - Volatility: `Natr`, `StdDev`, `UlcerIndex`, `HistoricalVolatility`, + `BollingerBandwidth`, `PercentB`, `SuperTrend`, `ChandelierExit`, + `ChandeKrollStop`, `AtrTrailingStop`. + - Volume: `Adl`, `VolumePriceTrend`, `ChaikinMoneyFlow`, + `ChaikinOscillator`, `ForceIndex`, `EaseOfMovement`. + - Statistics: `TypicalPrice`, `MedianPrice`, `WeightedClose`, + `LinearRegression`, `LinRegSlope`. - `TickAggregator::with_gap_fill` — opt-in mode that emits a flat placeholder candle for every empty bucket between two ticks, keeping the candle series evenly spaced for downstream indicators. diff --git a/README.md b/README.md index 17e2c633..0949fd63 100644 --- a/README.md +++ b/README.md @@ -95,16 +95,17 @@ python -m benchmarks.compare_libraries ## Indicators -25 streaming-first indicators across four families. Every one passes the -`batch == streaming` equivalence test, reference-value tests, and reset -semantics tests. +63 streaming-first indicators across four families plus a statistics group. +Every one passes the `batch == streaming` equivalence test, reference-value +tests, and reset semantics tests. | Family | Indicators | |-------------|-----------| -| Trend | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA | -| Momentum | RSI (Wilder), MACD, Stochastic, CCI, ROC, Williams %R, ADX (+DI/-DI), MFI, TRIX, Awesome Oscillator, Aroon | -| Volatility | Bollinger Bands, ATR, Keltner Channels, Donchian Channels, Parabolic SAR | -| Volume | OBV, VWAP (cumulative + rolling) | +| Trend | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA | +| Momentum | RSI (Wilder), MACD, Stochastic, CCI, ROC, Williams %R, ADX (+DI/-DI), MFI, TRIX, Awesome Oscillator, Aroon, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, PPO, DPO, Coppock, Aroon Oscillator, Vortex, Mass Index | +| Volatility | Bollinger Bands, ATR, Keltner Channels, Donchian Channels, Parabolic SAR, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop | +| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement | +| Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope | Adding a new indicator means implementing one trait in Rust; all four bindings inherit it automatically. @@ -174,7 +175,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 25 indicators +│ ├── wickra-core/ core engine + all 63 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) │ │ + benches/ and examples/backtest.rs │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index b88eb4aa..3ac46ae8 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -1,5 +1,5 @@ // Comprehensive tests for the Wickra Node bindings: streaming-vs-batch -// equivalence, reference values, and lifecycle methods across all 25 +// equivalence, reference values, and lifecycle methods across all 63 // indicators. Ported from the Python test_streaming_vs_batch / test_known_values // suites. @@ -36,6 +36,25 @@ const scalarFactories = { ROC: () => new wickra.ROC(12), TRIX: () => new wickra.TRIX(9), KAMA: () => new wickra.KAMA(10, 2, 30), + SMMA: () => new wickra.SMMA(14), + TRIMA: () => new wickra.TRIMA(20), + ZLEMA: () => new wickra.ZLEMA(14), + T3: () => new wickra.T3(5, 0.7), + MOM: () => new wickra.MOM(10), + CMO: () => new wickra.CMO(14), + TSI: () => new wickra.TSI(25, 13), + PMO: () => new wickra.PMO(35, 20), + StochRSI: () => new wickra.StochRSI(14, 14), + PPO: () => new wickra.PPO(12, 26), + DPO: () => new wickra.DPO(20), + Coppock: () => new wickra.Coppock(14, 11, 10), + StdDev: () => new wickra.StdDev(20), + UlcerIndex: () => new wickra.UlcerIndex(14), + HistoricalVolatility: () => new wickra.HistoricalVolatility(20, 252), + BollingerBandwidth: () => new wickra.BollingerBandwidth(20, 2), + PercentB: () => new wickra.PercentB(20, 2), + LinearRegression: () => new wickra.LinearRegression(14), + LinRegSlope: () => new wickra.LinRegSlope(14), }; for (const [name, make] of Object.entries(scalarFactories)) { @@ -61,6 +80,21 @@ const candleScalar = { VWAP: { make: () => new wickra.VWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, AwesomeOscillator: { make: () => new wickra.AwesomeOscillator(5, 34), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, OBV: { make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, + VWMA: { make: () => new wickra.VWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, + UltimateOscillator: { make: () => new wickra.UltimateOscillator(7, 14, 28), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + AroonOscillator: { make: () => new wickra.AroonOscillator(14), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, + NATR: { make: () => new wickra.NATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + MassIndex: { make: () => new wickra.MassIndex(9, 25), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, + ADL: { make: () => new wickra.ADL(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, + VolumePriceTrend: { make: () => new wickra.VolumePriceTrend(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, + ChaikinMoneyFlow: { make: () => new wickra.ChaikinMoneyFlow(20), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, + ChaikinOscillator: { make: () => new wickra.ChaikinOscillator(3, 10), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, + ForceIndex: { make: () => new wickra.ForceIndex(13), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, + EaseOfMovement: { make: () => new wickra.EaseOfMovement(14, 1e8), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) }, + AtrTrailingStop: { make: () => new wickra.AtrTrailingStop(14, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + TypicalPrice: { make: () => new wickra.TypicalPrice(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + MedianPrice: { make: () => new wickra.MedianPrice(), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, + WeightedClose: { make: () => new wickra.WeightedClose(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, }; for (const [name, d] of Object.entries(candleScalar)) { @@ -85,6 +119,10 @@ const multi = { Keltner: { make: () => new wickra.Keltner(20, 10, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, Donchian: { make: () => new wickra.Donchian(20), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, Aroon: { make: () => new wickra.Aroon(14), fields: ['up', 'down'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, + Vortex: { make: () => new wickra.Vortex(14), fields: ['plus', 'minus'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + SuperTrend: { make: () => new wickra.SuperTrend(10, 3), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + ChandelierExit: { make: () => new wickra.ChandelierExit(22, 3), fields: ['longStop', 'shortStop'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + ChandeKrollStop: { make: () => new wickra.ChandeKrollStop(10, 1, 9), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, }; for (const [name, d] of Object.entries(multi)) { @@ -163,3 +201,34 @@ test('MACD histogram equals macd minus signal', () => { assert.ok(v); assert.ok(Math.abs(v.histogram - (v.macd - v.signal)) < 1e-9); }); + +test('TypicalPrice reference value', () => { + // (high + low + close) / 3 = (12 + 6 + 9) / 3 = 9. + assert.equal(new wickra.TypicalPrice().update(12, 6, 9), 9); +}); + +test('ChaikinMoneyFlow(2) reference value equals 0.5', () => { + // Bar 1 closes at the high (MFV +100); bar 2 closes mid-range (MFV 0). + const cmf = new wickra.ChaikinMoneyFlow(2); + assert.equal(cmf.update(10, 8, 10, 100), null); + assert.ok(Math.abs(cmf.update(12, 8, 10, 100) - 0.5) < 1e-9); +}); + +test('LinearRegression(3) reference values', () => { + // Least-squares line through [1, 2, 9] is y = 4x; endpoint 4·2 = 8. + const out = new wickra.LinearRegression(3).batch([1, 2, 9]); + assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1])); + assert.ok(Math.abs(out[2] - 8) < 1e-9); +}); + +test('SuperTrend flat market holds the lower band and an uptrend', () => { + // Flat candles: ATR 2, hl2 10, lower band 10 - 3·2 = 4. + const n = 20; + const out = new wickra.SuperTrend(5, 3).batch( + Array(n).fill(11), + Array(n).fill(9), + Array(n).fill(10), + ); + assert.ok(Math.abs(out[2 * n - 2] - 4) < 1e-9); // value + assert.equal(out[2 * n - 1], 1); // direction +}); diff --git a/bindings/node/index.js b/bindings/node/index.js index b9348f46..976a0837 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, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, AroonOscillator, Vortex, MassIndex, NATR, StdDev, UlcerIndex, HistoricalVolatility, BollingerBandwidth, PercentB, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, 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, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -325,41 +325,11 @@ module.exports.TRIX = TRIX module.exports.SMMA = SMMA module.exports.TRIMA = TRIMA module.exports.ZLEMA = ZLEMA -module.exports.T3 = T3 -module.exports.VWMA = VWMA module.exports.MOM = MOM module.exports.CMO = CMO -module.exports.TSI = TSI -module.exports.PMO = PMO -module.exports.StochRSI = StochRSI -module.exports.UltimateOscillator = UltimateOscillator -module.exports.PPO = PPO module.exports.DPO = DPO -module.exports.Coppock = Coppock -module.exports.AroonOscillator = AroonOscillator -module.exports.Vortex = Vortex -module.exports.MassIndex = MassIndex -module.exports.NATR = NATR module.exports.StdDev = StdDev module.exports.UlcerIndex = UlcerIndex -module.exports.HistoricalVolatility = HistoricalVolatility -module.exports.BollingerBandwidth = BollingerBandwidth -module.exports.PercentB = PercentB -module.exports.ADL = ADL -module.exports.VolumePriceTrend = VolumePriceTrend -module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow -module.exports.ChaikinOscillator = ChaikinOscillator -module.exports.ForceIndex = ForceIndex -module.exports.EaseOfMovement = EaseOfMovement -module.exports.SuperTrend = SuperTrend -module.exports.ChandelierExit = ChandelierExit -module.exports.ChandeKrollStop = ChandeKrollStop -module.exports.AtrTrailingStop = AtrTrailingStop -module.exports.TypicalPrice = TypicalPrice -module.exports.MedianPrice = MedianPrice -module.exports.WeightedClose = WeightedClose -module.exports.LinearRegression = LinearRegression -module.exports.LinRegSlope = LinRegSlope module.exports.MACD = MACD module.exports.BollingerBands = BollingerBands module.exports.ATR = ATR @@ -376,3 +346,33 @@ module.exports.VWAP = VWAP module.exports.AwesomeOscillator = AwesomeOscillator module.exports.Aroon = Aroon module.exports.KAMA = KAMA +module.exports.T3 = T3 +module.exports.TSI = TSI +module.exports.PMO = PMO +module.exports.ADL = ADL +module.exports.VolumePriceTrend = VolumePriceTrend +module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow +module.exports.ChaikinOscillator = ChaikinOscillator +module.exports.ForceIndex = ForceIndex +module.exports.EaseOfMovement = EaseOfMovement +module.exports.SuperTrend = SuperTrend +module.exports.ChandelierExit = ChandelierExit +module.exports.ChandeKrollStop = ChandeKrollStop +module.exports.AtrTrailingStop = AtrTrailingStop +module.exports.TypicalPrice = TypicalPrice +module.exports.MedianPrice = MedianPrice +module.exports.WeightedClose = WeightedClose +module.exports.LinearRegression = LinearRegression +module.exports.LinRegSlope = LinRegSlope +module.exports.BollingerBandwidth = BollingerBandwidth +module.exports.PercentB = PercentB +module.exports.NATR = NATR +module.exports.HistoricalVolatility = HistoricalVolatility +module.exports.AroonOscillator = AroonOscillator +module.exports.Vortex = Vortex +module.exports.MassIndex = MassIndex +module.exports.StochRSI = StochRSI +module.exports.UltimateOscillator = UltimateOscillator +module.exports.PPO = PPO +module.exports.Coppock = Coppock +module.exports.VWMA = VWMA diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index b9919c0d..d65eb202 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -25,58 +25,144 @@ from __future__ import annotations from ._wickra import ( __version__, - ADX, - ATR, - Aroon, - AwesomeOscillator, - BollingerBands, - CCI, - DEMA, - Donchian, + # Trend + SMA, EMA, + WMA, + DEMA, + TEMA, HMA, KAMA, - Keltner, - MACD, - MFI, - OBV, - PSAR, - ROC, + SMMA, + TRIMA, + ZLEMA, + T3, + VWMA, + # Momentum RSI, - SMA, + MACD, Stochastic, - TEMA, - TRIX, - VWAP, + CCI, + ROC, WilliamsR, - WMA, + ADX, + MFI, + TRIX, + AwesomeOscillator, + Aroon, + MOM, + CMO, + TSI, + PMO, + StochRSI, + UltimateOscillator, + PPO, + DPO, + Coppock, + AroonOscillator, + Vortex, + MassIndex, + # Volatility + BollingerBands, + ATR, + Keltner, + Donchian, + PSAR, + NATR, + StdDev, + UlcerIndex, + HistoricalVolatility, + BollingerBandwidth, + PercentB, + SuperTrend, + ChandelierExit, + ChandeKrollStop, + AtrTrailingStop, + # Volume + OBV, + VWAP, + ADL, + VolumePriceTrend, + ChaikinMoneyFlow, + ChaikinOscillator, + ForceIndex, + EaseOfMovement, + # Statistics + TypicalPrice, + MedianPrice, + WeightedClose, + LinearRegression, + LinRegSlope, ) __all__ = [ "__version__", + # Trend "SMA", "EMA", "WMA", - "RSI", - "MACD", - "BollingerBands", - "ATR", - "Stochastic", - "OBV", "DEMA", "TEMA", "HMA", "KAMA", + "SMMA", + "TRIMA", + "ZLEMA", + "T3", + "VWMA", + # Momentum + "RSI", + "MACD", + "Stochastic", "CCI", "ROC", "WilliamsR", "ADX", "MFI", "TRIX", - "PSAR", - "Keltner", - "Donchian", - "VWAP", "AwesomeOscillator", "Aroon", + "MOM", + "CMO", + "TSI", + "PMO", + "StochRSI", + "UltimateOscillator", + "PPO", + "DPO", + "Coppock", + "AroonOscillator", + "Vortex", + "MassIndex", + # Volatility + "BollingerBands", + "ATR", + "Keltner", + "Donchian", + "PSAR", + "NATR", + "StdDev", + "UlcerIndex", + "HistoricalVolatility", + "BollingerBandwidth", + "PercentB", + "SuperTrend", + "ChandelierExit", + "ChandeKrollStop", + "AtrTrailingStop", + # Volume + "OBV", + "VWAP", + "ADL", + "VolumePriceTrend", + "ChaikinMoneyFlow", + "ChaikinOscillator", + "ForceIndex", + "EaseOfMovement", + # Statistics + "TypicalPrice", + "MedianPrice", + "WeightedClose", + "LinearRegression", + "LinRegSlope", ] diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py new file mode 100644 index 00000000..b3229b26 --- /dev/null +++ b/bindings/python/tests/test_new_indicators.py @@ -0,0 +1,253 @@ +"""Streaming-vs-batch, shape and reference-value tests for the F1-F12 families. + +Every indicator added since the original 25 is exercised here. The central +contract is the same as the rest of the suite: ``batch(...)`` must equal +repeated streaming ``update(...)`` across the whole warmup -> steady-state +transition, and batch shapes must match the input length. +""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +import wickra as ta + + +def _eq_nan(a: np.ndarray, b: np.ndarray, tol: float = 1e-9) -> bool: + """Compare two float arrays treating NaN positions as equal.""" + a = np.asarray(a, dtype=np.float64) + b = np.asarray(b, dtype=np.float64) + if a.shape != b.shape: + return False + both_nan = np.isnan(a) & np.isnan(b) + return bool(np.all(np.where(both_nan, 0.0, np.abs(a - b)) <= tol)) + + +@pytest.fixture +def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Synthetic high / low / close / volume series, 200 bars.""" + t = np.arange(200, dtype=np.float64) + close = 100.0 + np.sin(t * 0.15) * 8.0 + np.cos(t * 0.32) * 3.0 + spread = 0.5 + np.abs(np.sin(t * 0.07)) + high = close + spread + low = close - spread + volume = 1000.0 + (t % 7) * 50.0 + return high, low, close, volume + + +# --- Scalar (f64 -> f64) indicators --------------------------------------- + +SCALAR = [ + (ta.SMMA, (14,)), + (ta.TRIMA, (20,)), + (ta.ZLEMA, (14,)), + (ta.T3, (5, 0.7)), + (ta.MOM, (10,)), + (ta.CMO, (14,)), + (ta.TSI, (25, 13)), + (ta.PMO, (35, 20)), + (ta.StochRSI, (14, 14)), + (ta.PPO, (12, 26)), + (ta.DPO, (20,)), + (ta.Coppock, (14, 11, 10)), + (ta.StdDev, (20,)), + (ta.UlcerIndex, (14,)), + (ta.HistoricalVolatility, (20, 252)), + (ta.BollingerBandwidth, (20, 2.0)), + (ta.PercentB, (20, 2.0)), + (ta.LinearRegression, (14,)), + (ta.LinRegSlope, (14,)), +] + + +@pytest.mark.parametrize("cls, args", SCALAR, ids=[c.__name__ for c, _ in SCALAR]) +def test_scalar_streaming_matches_batch(cls, args, sine_prices): + batch = cls(*args).batch(sine_prices) + assert batch.shape == sine_prices.shape + assert batch.dtype == np.float64 + + streamer = cls(*args) + streamed = [] + for p in sine_prices: + v = streamer.update(float(p)) + streamed.append(math.nan if v is None else float(v)) + assert _eq_nan(batch, np.array(streamed, dtype=np.float64)) + + +# --- Candle-input, single-output indicators ------------------------------- +# +# Each entry is (factory, batch-call). Streaming always feeds the full +# 6-tuple candle; the batch helper takes only the columns it needs. + +CANDLE_SCALAR = { + "VWMA": (lambda: ta.VWMA(20), lambda ind, h, l, c, v: ind.batch(c, v)), + "UltimateOscillator": ( + lambda: ta.UltimateOscillator(7, 14, 28), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), + "AroonOscillator": ( + lambda: ta.AroonOscillator(14), + lambda ind, h, l, c, v: ind.batch(h, l), + ), + "NATR": (lambda: ta.NATR(14), lambda ind, h, l, c, v: ind.batch(h, l, c)), + "MassIndex": (lambda: ta.MassIndex(9, 25), lambda ind, h, l, c, v: ind.batch(h, l)), + "ADL": (lambda: ta.ADL(), lambda ind, h, l, c, v: ind.batch(h, l, c, v)), + "VolumePriceTrend": ( + lambda: ta.VolumePriceTrend(), + lambda ind, h, l, c, v: ind.batch(c, v), + ), + "ChaikinMoneyFlow": ( + lambda: ta.ChaikinMoneyFlow(20), + lambda ind, h, l, c, v: ind.batch(h, l, c, v), + ), + "ChaikinOscillator": ( + lambda: ta.ChaikinOscillator(3, 10), + lambda ind, h, l, c, v: ind.batch(h, l, c, v), + ), + "ForceIndex": ( + lambda: ta.ForceIndex(13), + lambda ind, h, l, c, v: ind.batch(c, v), + ), + "EaseOfMovement": ( + lambda: ta.EaseOfMovement(14), + lambda ind, h, l, c, v: ind.batch(h, l, v), + ), + "AtrTrailingStop": ( + lambda: ta.AtrTrailingStop(14, 3.0), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), + "TypicalPrice": ( + lambda: ta.TypicalPrice(), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), + "MedianPrice": ( + lambda: ta.MedianPrice(), + lambda ind, h, l, c, v: ind.batch(h, l), + ), + "WeightedClose": ( + lambda: ta.WeightedClose(), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), +} + + +@pytest.mark.parametrize("name", list(CANDLE_SCALAR)) +def test_candle_scalar_streaming_matches_batch(name, ohlcv): + high, low, close, volume = ohlcv + make, batch_call = CANDLE_SCALAR[name] + + batch = batch_call(make(), high, low, close, volume) + assert batch.shape == close.shape + + streamer = make() + streamed = [] + for i in range(close.size): + candle = ( + float(close[i]), + float(high[i]), + float(low[i]), + float(close[i]), + float(volume[i]), + i, + ) + v = streamer.update(candle) + streamed.append(math.nan if v is None else float(v)) + assert _eq_nan(batch, np.array(streamed, dtype=np.float64)), f"{name} mismatch" + + +# --- Candle-input, multi-output indicators -------------------------------- + +MULTI = { + "Vortex": (lambda: ta.Vortex(14), lambda ind, h, l, c, v: ind.batch(h, l, c)), + "SuperTrend": ( + lambda: ta.SuperTrend(10, 3.0), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), + "ChandelierExit": ( + lambda: ta.ChandelierExit(22, 3.0), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), + "ChandeKrollStop": ( + lambda: ta.ChandeKrollStop(10, 1.0, 9), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), +} + + +@pytest.mark.parametrize("name", list(MULTI)) +def test_multi_streaming_matches_batch(name, ohlcv): + high, low, close, volume = ohlcv + make, batch_call = MULTI[name] + + batch = batch_call(make(), high, low, close, volume) + assert batch.shape == (close.size, 2) + + streamer = make() + rows = [] + for i in range(close.size): + candle = ( + float(close[i]), + float(high[i]), + float(low[i]), + float(close[i]), + float(volume[i]), + i, + ) + v = streamer.update(candle) + rows.append([math.nan, math.nan] if v is None else list(v)) + assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch" + + +# --- Reference values ----------------------------------------------------- + + +def test_typical_price_reference(): + # (high + low + close) / 3 = (12 + 6 + 9) / 3 = 9. + assert ta.TypicalPrice().update((9.0, 12.0, 6.0, 9.0, 1.0, 0)) == pytest.approx(9.0) + + +def test_median_price_reference(): + # (high + low) / 2 = (12 + 8) / 2 = 10. + assert ta.MedianPrice().update((10.0, 12.0, 8.0, 11.0, 1.0, 0)) == pytest.approx(10.0) + + +def test_weighted_close_reference(): + # (high + low + 2*close) / 4 = (12 + 8 + 22) / 4 = 10.5. + assert ta.WeightedClose().update((10.0, 12.0, 8.0, 11.0, 1.0, 0)) == pytest.approx( + 10.5 + ) + + +def test_chaikin_money_flow_reference(): + cmf = ta.ChaikinMoneyFlow(2) + assert cmf.update((8.0, 10.0, 8.0, 10.0, 100.0, 0)) is None + assert cmf.update((10.0, 12.0, 8.0, 10.0, 100.0, 1)) == pytest.approx(0.5) + + +def test_linear_regression_reference(): + out = ta.LinearRegression(3).batch(np.array([1.0, 2.0, 9.0])) + assert math.isnan(out[0]) and math.isnan(out[1]) + assert out[2] == pytest.approx(8.0) + + +def test_linreg_slope_reference(): + out = ta.LinRegSlope(3).batch(np.array([1.0, 2.0, 9.0])) + assert math.isnan(out[0]) and math.isnan(out[1]) + assert out[2] == pytest.approx(4.0) + + +# --- Lifecycle ------------------------------------------------------------ + + +def test_new_indicators_expose_lifecycle(): + instances = [make() for make, _ in CANDLE_SCALAR.values()] + instances += [make() for make, _ in MULTI.values()] + instances += [cls(*args) for cls, args in SCALAR] + for ind in instances: + assert ind.is_ready() is False + assert ind.warmup_period() >= 1 + ind.reset() + assert ind.is_ready() is False diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 428ae813..e05af0ab 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -7,9 +7,10 @@ Node.js, WebAssembly, and Rust itself. The same `update` call you write inside a live trading loop also drives the historical backtest of that same strategy — there is no second code path that drifts behind the streaming one. -The project ships 25 indicators across the four classical families (trend, -momentum, volatility, volume) and a small set of supporting types (`Candle`, -`Tick`, `Chain`). The Rust core forbids `unsafe`, so every binding inherits a +The project ships 63 indicators across the four classical families (trend, +momentum, volatility, volume) plus a statistics group, and a small set of +supporting types (`Candle`, `Tick`, `Chain`). The Rust core forbids `unsafe`, +so every binding inherits a memory-safe implementation. Install is one command on every supported platform: `pip install wickra`, `cargo add wickra`, `npm install wickra` — no system compilers, no C dependencies, no headers. diff --git a/docs/wiki/Warmup-Periods.md b/docs/wiki/Warmup-Periods.md index 21739659..328f9b4d 100644 --- a/docs/wiki/Warmup-Periods.md +++ b/docs/wiki/Warmup-Periods.md @@ -38,10 +38,44 @@ index" in 0-indexed terms is `warmup_period − 1`. | `Trix` | `Trix::new(15)` | `3 * period - 1` | 44 | 44th | | `AwesomeOscillator` | `AwesomeOscillator::new(5, 34)` | `slow_period` | 34 | 34th | | `Atr` | `Atr::new(14)` | `period` | 14 | 14th | -| `Psar` | `Psar::new(0.02, 0.20)` | constant `2` | 2 | 2nd | +| `Psar` | `Psar::new(0.02, 0.02, 0.20)` | constant `2` | 2 | 2nd | | `Obv` | `Obv::new()` | constant `1` | 1 | 1st | | `Vwap` | `Vwap::new()` | constant `1` | 1 | 1st | | `RollingVwap` | `RollingVwap::new(20)` | `period` | 20 | 20th | +| `Smma` | `Smma::new(14)` | `period` | 14 | 14th | +| `Trima` | `Trima::new(20)` | `period` | 20 | 20th | +| `Zlema` | `Zlema::new(14)` | `lag + period` (`lag = (period − 1) / 2`) | 20 | 20th | +| `T3` | `T3::new(5, 0.7)` | `6 * period - 5` | 25 | 25th | +| `Vwma` | `Vwma::new(20)` | `period` | 20 | 20th | +| `Mom` | `Mom::new(10)` | `period + 1` | 11 | 11th | +| `Cmo` | `Cmo::new(14)` | `period + 1` | 15 | 15th | +| `Tsi` | `Tsi::new(25, 13)` | `long + short` | 38 | 38th | +| `Pmo` | `Pmo::new(35, 20)` | constant `2` | 2 | 2nd | +| `StochRsi` | `StochRsi::new(14, 14)` | `rsi_period + stoch_period` | 28 | 28th | +| `UltimateOscillator` | `UltimateOscillator::new(7, 14, 28)` | `max(short, mid, long) + 1` | 29 | 29th | +| `Ppo` | `Ppo::new(12, 26)` | `slow` | 26 | 26th | +| `Dpo` | `Dpo::new(20)` | `max(period, period / 2 + 2)` | 20 | 20th | +| `Coppock` | `Coppock::new(14, 11, 10)` | `max(roc_long, roc_short) + wma_period` | 24 | 24th | +| `AroonOscillator` | `AroonOscillator::new(14)` | `period + 1` | 15 | 15th | +| `MassIndex` | `MassIndex::new(9, 25)` | `2 * ema_period + sum_period - 2` | 41 | 41st | +| `Natr` | `Natr::new(14)` | `period` | 14 | 14th | +| `StdDev` | `StdDev::new(20)` | `period` | 20 | 20th | +| `UlcerIndex` | `UlcerIndex::new(14)` | `2 * period - 1` | 27 | 27th | +| `HistoricalVolatility` | `HistoricalVolatility::new(20, 252)` | `period + 1` | 21 | 21st | +| `BollingerBandwidth` | `BollingerBandwidth::new(20, 2.0)` | `period` | 20 | 20th | +| `PercentB` | `PercentB::new(20, 2.0)` | `period` | 20 | 20th | +| `AtrTrailingStop` | `AtrTrailingStop::new(14, 3.0)` | `atr_period` | 14 | 14th | +| `Adl` | `Adl::new()` | constant `1` | 1 | 1st | +| `VolumePriceTrend` | `VolumePriceTrend::new()` | constant `1` | 1 | 1st | +| `ChaikinMoneyFlow` | `ChaikinMoneyFlow::new(20)` | `period` | 20 | 20th | +| `ChaikinOscillator` | `ChaikinOscillator::new(3, 10)` | `slow` | 10 | 10th | +| `ForceIndex` | `ForceIndex::new(13)` | `period + 1` | 14 | 14th | +| `EaseOfMovement` | `EaseOfMovement::new(14)` | `period + 1` | 15 | 15th | +| `TypicalPrice` | `TypicalPrice::new()` | constant `1` | 1 | 1st | +| `MedianPrice` | `MedianPrice::new()` | constant `1` | 1 | 1st | +| `WeightedClose` | `WeightedClose::new()` | constant `1` | 1 | 1st | +| `LinearRegression` | `LinearRegression::new(14)` | `period` | 14 | 14th | +| `LinRegSlope` | `LinRegSlope::new(14)` | `period` | 14 | 14th | ## Multi-output indicators @@ -59,6 +93,10 @@ ready" to "ready" together — there are no rows that have a `signal` but no | `Aroon` | `Aroon::new(14)` | `period + 1` | 15 | 15th | `up`, `down` | | `Keltner` | `Keltner::new(20, 10, 2.0)` | `ema_period.max(atr_period)` | 20 | 20th | `upper`, `middle`, `lower` | | `Donchian` | `Donchian::new(20)` | `period` | 20 | 20th | `upper`, `middle`, `lower` | +| `Vortex` | `Vortex::new(14)` | `period + 1` | 15 | 15th | `plus`, `minus` | +| `SuperTrend` | `SuperTrend::new(10, 3.0)` | `atr_period` | 10 | 10th | `value`, `direction` | +| `ChandelierExit` | `ChandelierExit::new(22, 3.0)` | `period` | 22 | 22nd | `long_stop`, `short_stop` | +| `ChandeKrollStop` | `ChandeKrollStop::new(10, 1.0, 9)` | `atr_period + stop_period - 1` | 18 | 18th | `stop_long`, `stop_short` | ## "Off-by-one" cases worth memorising