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.
This commit is contained in:
kingchenc
2026-05-22 20:04:13 +02:00
parent 2d0ee926c5
commit 2f3b5cc3be
8 changed files with 534 additions and 73 deletions
+13
View File
@@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### 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 - `TickAggregator::with_gap_fill` — opt-in mode that emits a flat placeholder
candle for every empty bucket between two ticks, keeping the candle series candle for every empty bucket between two ticks, keeping the candle series
evenly spaced for downstream indicators. evenly spaced for downstream indicators.
+9 -8
View File
@@ -95,16 +95,17 @@ python -m benchmarks.compare_libraries
## Indicators ## Indicators
25 streaming-first indicators across four families. Every one passes the 63 streaming-first indicators across four families plus a statistics group.
`batch == streaming` equivalence test, reference-value tests, and reset Every one passes the `batch == streaming` equivalence test, reference-value
semantics tests. tests, and reset semantics tests.
| Family | Indicators | | Family | Indicators |
|-------------|-----------| |-------------|-----------|
| Trend | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA | | 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 | | 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 | | 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) | | 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 Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically. inherit it automatically.
@@ -174,7 +175,7 @@ A Python live-trading example using the public `websockets` package lives at
``` ```
wickra/ wickra/
├── crates/ ├── crates/
│ ├── wickra-core/ core engine + all 25 indicators │ ├── wickra-core/ core engine + all 63 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) │ ├── wickra/ top-level facade crate (publishes on crates.io)
│ │ + benches/ and examples/backtest.rs │ │ + benches/ and examples/backtest.rs
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
+70 -1
View File
@@ -1,5 +1,5 @@
// Comprehensive tests for the Wickra Node bindings: streaming-vs-batch // 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 // indicators. Ported from the Python test_streaming_vs_batch / test_known_values
// suites. // suites.
@@ -36,6 +36,25 @@ const scalarFactories = {
ROC: () => new wickra.ROC(12), ROC: () => new wickra.ROC(12),
TRIX: () => new wickra.TRIX(9), TRIX: () => new wickra.TRIX(9),
KAMA: () => new wickra.KAMA(10, 2, 30), 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)) { 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) }, 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) }, 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) }, 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)) { 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) }, 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) }, 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) }, 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)) { for (const [name, d] of Object.entries(multi)) {
@@ -163,3 +201,34 @@ test('MACD histogram equals macd minus signal', () => {
assert.ok(v); assert.ok(v);
assert.ok(Math.abs(v.histogram - (v.macd - v.signal)) < 1e-9); 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
});
+31 -31
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`) 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.version = version
module.exports.SMA = SMA module.exports.SMA = SMA
@@ -325,41 +325,11 @@ module.exports.TRIX = TRIX
module.exports.SMMA = SMMA module.exports.SMMA = SMMA
module.exports.TRIMA = TRIMA module.exports.TRIMA = TRIMA
module.exports.ZLEMA = ZLEMA module.exports.ZLEMA = ZLEMA
module.exports.T3 = T3
module.exports.VWMA = VWMA
module.exports.MOM = MOM module.exports.MOM = MOM
module.exports.CMO = CMO 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.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.StdDev = StdDev
module.exports.UlcerIndex = UlcerIndex 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.MACD = MACD
module.exports.BollingerBands = BollingerBands module.exports.BollingerBands = BollingerBands
module.exports.ATR = ATR module.exports.ATR = ATR
@@ -376,3 +346,33 @@ module.exports.VWAP = VWAP
module.exports.AwesomeOscillator = AwesomeOscillator module.exports.AwesomeOscillator = AwesomeOscillator
module.exports.Aroon = Aroon module.exports.Aroon = Aroon
module.exports.KAMA = KAMA 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
+115 -29
View File
@@ -25,58 +25,144 @@ from __future__ import annotations
from ._wickra import ( from ._wickra import (
__version__, __version__,
ADX, # Trend
ATR, SMA,
Aroon,
AwesomeOscillator,
BollingerBands,
CCI,
DEMA,
Donchian,
EMA, EMA,
WMA,
DEMA,
TEMA,
HMA, HMA,
KAMA, KAMA,
Keltner, SMMA,
MACD, TRIMA,
MFI, ZLEMA,
OBV, T3,
PSAR, VWMA,
ROC, # Momentum
RSI, RSI,
SMA, MACD,
Stochastic, Stochastic,
TEMA, CCI,
TRIX, ROC,
VWAP,
WilliamsR, 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__ = [ __all__ = [
"__version__", "__version__",
# Trend
"SMA", "SMA",
"EMA", "EMA",
"WMA", "WMA",
"RSI",
"MACD",
"BollingerBands",
"ATR",
"Stochastic",
"OBV",
"DEMA", "DEMA",
"TEMA", "TEMA",
"HMA", "HMA",
"KAMA", "KAMA",
"SMMA",
"TRIMA",
"ZLEMA",
"T3",
"VWMA",
# Momentum
"RSI",
"MACD",
"Stochastic",
"CCI", "CCI",
"ROC", "ROC",
"WilliamsR", "WilliamsR",
"ADX", "ADX",
"MFI", "MFI",
"TRIX", "TRIX",
"PSAR",
"Keltner",
"Donchian",
"VWAP",
"AwesomeOscillator", "AwesomeOscillator",
"Aroon", "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",
] ]
@@ -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
+4 -3
View File
@@ -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 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. strategy — there is no second code path that drifts behind the streaming one.
The project ships 25 indicators across the four classical families (trend, The project ships 63 indicators across the four classical families (trend,
momentum, volatility, volume) and a small set of supporting types (`Candle`, momentum, volatility, volume) plus a statistics group, and a small set of
`Tick`, `Chain`). The Rust core forbids `unsafe`, so every binding inherits a 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 memory-safe implementation. Install is one command on every supported
platform: `pip install wickra`, `cargo add wickra`, `npm install wickra` — no platform: `pip install wickra`, `cargo add wickra`, `npm install wickra` — no
system compilers, no C dependencies, no headers. system compilers, no C dependencies, no headers.
+39 -1
View File
@@ -38,10 +38,44 @@ index" in 0-indexed terms is `warmup_period 1`.
| `Trix` | `Trix::new(15)` | `3 * period - 1` | 44 | 44th | | `Trix` | `Trix::new(15)` | `3 * period - 1` | 44 | 44th |
| `AwesomeOscillator` | `AwesomeOscillator::new(5, 34)` | `slow_period` | 34 | 34th | | `AwesomeOscillator` | `AwesomeOscillator::new(5, 34)` | `slow_period` | 34 | 34th |
| `Atr` | `Atr::new(14)` | `period` | 14 | 14th | | `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 | | `Obv` | `Obv::new()` | constant `1` | 1 | 1st |
| `Vwap` | `Vwap::new()` | constant `1` | 1 | 1st | | `Vwap` | `Vwap::new()` | constant `1` | 1 | 1st |
| `RollingVwap` | `RollingVwap::new(20)` | `period` | 20 | 20th | | `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 ## 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` | | `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` | | `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` | | `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 ## "Off-by-one" cases worth memorising