Files
wickra/bindings/python/tests/test_new_indicators.py
T
kingchenc 24e723fa7d feat: Family 02 Momentum Oscillators — RVI / PGO / KST / SMI / Laguerre / Connors / Inertia (#40)
* feat(rvi): add Relative Vigor Index

Dorsey's RVI = SMA(close - open, period) / SMA(high - low, period) over
a rolling window of period candles. Candle input, single parameter
period (default 10). Positive on average-bullish windows, negative on
average-bearish. Holds the previous value if the entire window has
zero range (denominator undefined).

Reference: Donald Dorsey, also pandas-ta rvi.

Touchpoints: rvi.rs + mod.rs + lib.rs re-export, PyRvi + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values reference,
RviNode (4-column OHLC batch) + index.d.ts/index.js + indicators.test
.js factory + reference, WasmRvi + make_candle_ohlc helper, candle-fuzz
target + criterion bench, README + CHANGELOG.

* feat(pgo): add Pretty Good Oscillator

Mark Johnson's PGO = (close - SMA(close, period)) / EMA(TR, period).
Counts roughly how many ATR-equivalents the close sits from its
period-bar mean. Candle input, single parameter period (default 14).
Johnson's heuristic uses +3/-3 crossings as entry signals.

Touchpoints: pgo.rs + mod.rs + lib.rs re-export, PyPgo + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values flat-close
reference, PgoNode (h/l/c) + index.d.ts/index.js + indicators.test.js
factory + reference, WasmPgo, candle-fuzz target + bench, README +
CHANGELOG.

* feat(kst): add Know Sure Thing (Pring)

Pring's long-horizon momentum oscillator: weighted sum of four
SMA-smoothed ROC series with fixed weights 1, 2, 3, 4, plus an SMA
signal line. Nine parameters (four ROC periods, four SMA periods, one
signal period); classic() applies Pring's recommended defaults.
Multi-output indicator emitting KstOutput { kst, signal }.

Touchpoints: kst.rs + mod.rs + lib.rs re-export, PyKst + __init__.py
+ test_new_indicators MULTI + test_known_values flat-input reference,
KstNode + KstValue + index.d.ts/index.js + indicators.test.js multi
factory + reference, WasmKst (manual JsValue object), scalar-fuzz
target (handled outside the f64-output drive helper), README +
CHANGELOG.

* feat(smi): add Stochastic Momentum Index (Blau)

Blau's doubly-EMA-smoothed bounded oscillator: measures the close's
displacement from the centre of the recent high-low range, scaled by
the smoothed range. Candle input, three parameters (period, d_period,
d2_period) with defaults 5 / 3 / 3.

Internally feeds both the displacement-EMA stack and the range-EMA
stack on every candle so they warm up in parallel (gating either
behind the other starves the second by one input).

Touchpoints: smi.rs + mod.rs + lib.rs re-export, PySmi + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values flat-input
reference, SmiNode + index.d.ts/index.js + indicators.test.js factory
+ reference, WasmSmi, candle-fuzz target, README + CHANGELOG.

* feat(laguerre-rsi): add Ehlers Laguerre RSI

Four-stage Laguerre polynomial filter wrapped in an RSI-style up/down
accumulator. Single gamma in [0, 1] (default 0.5) trades lag for
smoothness. State is seeded by setting all four L_i to the first input
so a constant series stays at the neutral 50. Output clamped to
[0, 100] to absorb floating-point rounding.

Reference: Ehlers, Time Warp - Without Space Travel, 2002.

Touchpoints: laguerre_rsi.rs + mod.rs + lib.rs re-export, PyLaguerreRsi
+ __init__.py + test_new_indicators SCALAR + test_known_values neutral
reference, LaguerreRsiNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmLaguerreRsi via scalar macro, scalar-fuzz
target, README + CHANGELOG.

* feat(connors-rsi): add Connors RSI (CRSI)

Larry Connors' 3-component aggregate: RSI(close), RSI(streak), and
PercentRank of the 1-period return over the last period_rank returns.
Each component is bounded in [0, 100] so the aggregate is too.
Three parameters (period_rsi, period_streak, period_rank) with
defaults 3 / 2 / 100. Streak tracks consecutive up/down runs (resets
to 0 on unchanged close).

Touchpoints: connors_rsi.rs + mod.rs + lib.rs re-export, PyConnorsRsi
+ __init__.py + test_new_indicators SCALAR + test_known_values bounded
reference, ConnorsRsiNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmConnorsRsi via scalar macro, scalar-fuzz
target, README + CHANGELOG.

* feat(inertia): add Dorsey Inertia (RVI + LinReg)

Donald Dorsey's Inertia — a LinearRegression smoothing of the RVI
series. Endpoint of an n-bar least-squares fit of RVI is the indicator
reading. Preserves trend direction while damping the ratio. Candle
input, two parameters (rvi_period, linreg_period) with defaults 14 / 20.

Touchpoints: inertia.rs + mod.rs + lib.rs re-export, PyInertia +
__init__.py + test_new_indicators CANDLE_SCALAR + test_known_values
constant reference, InertiaNode (4-column OHLC batch) + index.d.ts /
index.js + indicators.test.js factory + reference, WasmInertia,
candle-fuzz target, README + CHANGELOG.

* test(kst): Move KST out of MULTI dict (it is scalar-input)

KST sits in the MULTI dict (candle-input, multi-output) but its
update() takes a single f64, not a candle tuple. The shared streaming
loop in test_multi_streaming_matches_batch fed the OHLCV tuple in,
which crashed with `TypeError: argument 'value': must be real number,
not tuple` on every Python matrix entry.

Split into a new MULTI_SCALAR_INPUT dict with its own test function
that feeds the close-price stream as floats. KST is currently the
only such indicator; structure is ready for future scalar-input
multi-output additions (e.g. some MACD-shaped indicators).

* test(coverage): Cover SMI zero-range and ConnorsRsi zero-prev cold paths

codecov/patch on PR 40 flagged two uncovered defensive branches:
- SMI returns self.current early when the smoothed range collapses to
  zero (`r2 <= 0.0`) so the formula stays defined. Exercised by feeding
  bars where high == low.
- ConnorsRsi skips the ROC ring-buffer update when the previous price
  is exactly zero so the divide-by-zero in `(input - prev) / prev` is
  impossible. Exercised by seeding the first bar at 0.0.
2026-05-25 15:28:56 +02:00

372 lines
12 KiB
Python

"""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.ALMA, (9, 0.85, 6.0)),
(ta.McGinleyDynamic, (10,)),
(ta.FRAMA, (16,)),
(ta.VIDYA, (14, 9)),
(ta.JMA, (14, 0.0, 2)),
(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,)),
(ta.VerticalHorizontalFilter, (28,)),
(ta.ZScore, (20,)),
(ta.LinRegAngle, (14,)),
(ta.LaguerreRSI, (0.5,)),
(ta.ConnorsRSI, (3, 2, 100)),
]
@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)),
"RVI": (
# extract_candle pulls the open price from index 0 of the tuple; the
# streaming test below already builds candles with open == close, so
# match that here by passing close as the open column.
lambda: ta.RVI(10),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Inertia": (
lambda: ta.Inertia(14, 20),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"PGO": (lambda: ta.PGO(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"SMI": (lambda: ta.SMI(5, 3, 3), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"EVWMA": (lambda: ta.EVWMA(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),
),
"AcceleratorOscillator": (
lambda: ta.AcceleratorOscillator(5, 34, 5),
lambda ind, h, l, c, v: ind.batch(h, l),
),
"BalanceOfPower": (
# The streaming 6-tuple feeds open == close, so batch matches with
# the close column standing in for open.
lambda: ta.BalanceOfPower(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"ChoppinessIndex": (
lambda: ta.ChoppinessIndex(14),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"TrueRange": (
lambda: ta.TrueRange(),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"ChaikinVolatility": (
lambda: ta.ChaikinVolatility(10, 10),
lambda ind, h, l, c, v: ind.batch(h, l),
),
}
@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),
),
}
# --- Scalar-input, multi-output indicators --------------------------------
#
# Same shape contract as MULTI (batch returns (n, 2)) but streaming feeds a
# single float instead of a candle tuple.
MULTI_SCALAR_INPUT = {
"KST": (
lambda: ta.KST(10, 15, 20, 30, 10, 10, 10, 15, 9),
lambda ind, c: ind.batch(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"
@pytest.mark.parametrize("name", list(MULTI_SCALAR_INPUT))
def test_multi_scalar_streaming_matches_batch(name, ohlcv):
_, _, close, _ = ohlcv
make, batch_call = MULTI_SCALAR_INPUT[name]
batch = batch_call(make(), close)
assert batch.shape == (close.size, 2)
streamer = make()
rows = []
for p in close:
v = streamer.update(float(p))
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"
# --- Alligator (3-tuple output) -------------------------------------------
def test_alligator_streaming_matches_batch(ohlcv):
high, low, _, _ = ohlcv
alligator = ta.Alligator(13, 8, 5)
batch = alligator.batch(high, low)
assert batch.shape == (high.size, 3)
streamer = ta.Alligator(13, 8, 5)
rows = []
for i in range(high.size):
candle = (float(low[i]), float(high[i]), float(low[i]), float(low[i]), 0.0, i)
v = streamer.update(candle)
rows.append([math.nan, math.nan, math.nan] if v is None else list(v))
assert _eq_nan(batch, np.array(rows, dtype=np.float64)), "Alligator 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)
def test_balance_of_power_reference():
# (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
bop = ta.BalanceOfPower()
assert bop.update((10.0, 14.0, 10.0, 12.0, 1.0, 0)) == pytest.approx(0.5)
def test_true_range_reference():
tr = ta.TrueRange()
assert tr.update((11.0, 12.0, 8.0, 11.0, 1.0, 0)) == pytest.approx(4.0)
assert tr.update((9.5, 10.0, 9.0, 9.5, 1.0, 1)) == pytest.approx(2.0)
def test_linreg_angle_reference():
# A series rising by 1 per step has slope 1, and atan(1) = 45 degrees.
out = ta.LinRegAngle(5).batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]))
assert out[4] == pytest.approx(45.0)
def test_z_score_reference():
# Window [1, 3]: mean 2, population stddev 1; latest 3 -> z = 1.
out = ta.ZScore(2).batch(np.array([1.0, 3.0]))
assert math.isnan(out[0])
assert out[1] == pytest.approx(1.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]
instances.append(ta.Alligator(13, 8, 5))
for ind in instances:
assert ind.is_ready() is False
assert ind.warmup_period() >= 1
ind.reset()
assert ind.is_ready() is False