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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user