Files
wickra/bindings/node/__tests__/indicators.test.js
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

389 lines
20 KiB
JavaScript

// Comprehensive tests for the Wickra Node bindings: streaming-vs-batch
// equivalence, reference values, and lifecycle methods across all 71
// indicators. Ported from the Python test_streaming_vs_batch / test_known_values
// suites.
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
// Synthetic OHLCV series long enough to warm up every indicator.
const N = 120;
const close = Array.from({ length: N }, (_, i) => 100 + Math.sin(i * 0.2) * 10 + i * 0.1);
const high = close.map((c) => c + 1.5);
const low = close.map((c) => c - 1.5);
const volume = Array.from({ length: N }, (_, i) => 1000 + (i % 7) * 50);
const open = close.map((c) => c - 0.5);
function eq(a, b) {
if (Number.isNaN(a)) return Number.isNaN(b);
return Math.abs(a - b) < 1e-9;
}
function num(v) {
return v === null || v === undefined ? NaN : v;
}
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
SMA: () => new wickra.SMA(14),
EMA: () => new wickra.EMA(14),
WMA: () => new wickra.WMA(14),
RSI: () => new wickra.RSI(14),
DEMA: () => new wickra.DEMA(10),
TEMA: () => new wickra.TEMA(10),
HMA: () => new wickra.HMA(9),
ROC: () => new wickra.ROC(12),
TRIX: () => new wickra.TRIX(9),
KAMA: () => new wickra.KAMA(10, 2, 30),
ALMA: () => new wickra.ALMA(9, 0.85, 6.0),
McGinleyDynamic: () => new wickra.McGinleyDynamic(10),
FRAMA: () => new wickra.FRAMA(16),
VIDYA: () => new wickra.VIDYA(14, 9),
JMA: () => new wickra.JMA(14, 0, 2),
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),
VerticalHorizontalFilter: () => new wickra.VerticalHorizontalFilter(28),
ZScore: () => new wickra.ZScore(20),
LinRegAngle: () => new wickra.LinRegAngle(14),
LaguerreRSI: () => new wickra.LaguerreRSI(0.5),
ConnorsRSI: () => new wickra.ConnorsRSI(3, 2, 100),
};
for (const [name, make] of Object.entries(scalarFactories)) {
test(`${name}: streaming update matches batch`, () => {
const batch = make().batch(close);
const streaming = make();
assert.equal(batch.length, N);
for (let i = 0; i < N; i++) {
const s = num(streaming.update(close[i]));
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
}
// --- Scalar-output candle indicators: update(...) vs batch(...) ---
const candleScalar = {
ATR: { make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
CCI: { make: () => new wickra.CCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
WilliamsR: { make: () => new wickra.WilliamsR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
PSAR: { make: () => new wickra.PSAR(0.02, 0.02, 0.2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MFI: { make: () => new wickra.MFI(14), 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) },
RollingVWAP: { make: () => new wickra.RollingVWAP(20), 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) },
RVI: { make: () => new wickra.RVI(10), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Inertia: { make: () => new wickra.Inertia(14, 20), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
PGO: { make: () => new wickra.PGO(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
SMI: { make: () => new wickra.SMI(5, 3, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
EVWMA: { make: () => new wickra.EVWMA(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) },
AcceleratorOscillator: { make: () => new wickra.AcceleratorOscillator(5, 34, 5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
BalanceOfPower: { make: () => new wickra.BalanceOfPower(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ChoppinessIndex: { make: () => new wickra.ChoppinessIndex(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TrueRange: { make: () => new wickra.TrueRange(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ChaikinVolatility: { make: () => new wickra.ChaikinVolatility(10, 10), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
};
for (const [name, d] of Object.entries(candleScalar)) {
test(`${name}: streaming update matches batch`, () => {
const batch = d.batch(d.make());
const streaming = d.make();
assert.equal(batch.length, N);
for (let i = 0; i < N; i++) {
const s = num(d.step(streaming, i));
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
}
// --- Multi-output indicators: object update vs interleaved batch ---
const multi = {
KST: { make: () => new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9), fields: ['kst', 'signal'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
Alligator: { make: () => new wickra.Alligator(13, 8, 5), fields: ['jaw', 'teeth', 'lips'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
MACD: { make: () => new wickra.MACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
BollingerBands: { make: () => new wickra.BollingerBands(20, 2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
Stochastic: { make: () => new wickra.Stochastic(14, 3), fields: ['k', 'd'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ADX: { make: () => new wickra.ADX(14), fields: ['plusDi', 'minusDi', 'adx'], 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) },
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)) {
test(`${name}: streaming update matches interleaved batch`, () => {
const k = d.fields.length;
const batch = d.batch(d.make());
const streaming = d.make();
assert.equal(batch.length, N * k);
for (let i = 0; i < N; i++) {
const o = d.step(streaming, i);
d.fields.forEach((field, j) => {
const s = o === null || o === undefined ? NaN : o[field];
assert.ok(eq(s, batch[i * k + j]), `${name}.${field} mismatch at ${i}`);
});
}
});
}
// --- Lifecycle: every indicator exposes reset / isReady / warmupPeriod ---
test('every indicator exposes reset, isReady and warmupPeriod', () => {
const all = [
...Object.values(scalarFactories).map((f) => f()),
...Object.values(candleScalar).map((d) => d.make()),
...Object.values(multi).map((d) => d.make()),
];
for (const ind of all) {
assert.equal(typeof ind.reset, 'function');
assert.equal(typeof ind.isReady, 'function');
assert.equal(typeof ind.warmupPeriod, 'function');
assert.equal(ind.isReady(), false);
assert.ok(ind.warmupPeriod() >= 1);
}
});
test('reset returns an indicator to its un-warmed state', () => {
const sma = new wickra.SMA(5);
sma.batch([1, 2, 3, 4, 5]);
assert.equal(sma.isReady(), true);
sma.reset();
assert.equal(sma.isReady(), false);
assert.equal(sma.update(10), null);
});
// --- Reference values ---
test('SMA(3) reference values', () => {
const out = new wickra.SMA(3).batch([2, 4, 6, 8, 10]);
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
assert.equal(out[2], 4);
assert.equal(out[3], 6);
assert.equal(out[4], 8);
});
test('MFI(2) reference value equals 1200/23', () => {
// Candle 1 seeds; candle 2 (tp 12 > 10) +mf 1200; candle 3 (tp 11 < 12) -mf 1100.
const mfi = new wickra.MFI(2);
assert.equal(mfi.update(10, 10, 10, 100), null);
assert.equal(mfi.update(12, 12, 12, 100), null);
const v = mfi.update(11, 11, 11, 100);
assert.ok(Math.abs(v - 1200 / 23) < 1e-9);
});
test('RSI pure uptrend yields 100', () => {
const prices = Array.from({ length: 20 }, (_, i) => i + 1);
const out = new wickra.RSI(14).batch(prices);
for (let i = 14; i < out.length; i++) {
assert.equal(out[i], 100);
}
});
test('MACD histogram equals macd minus signal', () => {
const macd = new wickra.MACD(12, 26, 9);
let v = null;
for (let i = 1; i <= 60; i++) v = macd.update(i);
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
});
test('BalanceOfPower reference value', () => {
// (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
assert.ok(Math.abs(new wickra.BalanceOfPower().update(10, 14, 10, 12) - 0.5) < 1e-9);
});
test('TrueRange reference values', () => {
const tr = new wickra.TrueRange();
assert.equal(tr.update(12, 8, 11), 4); // no prev close -> high - low
assert.equal(tr.update(10, 9, 9.5), 2); // prev close 11 -> max(1, 1, 2)
});
test('LinRegAngle of a unit-slope series is 45 degrees', () => {
const out = new wickra.LinRegAngle(5).batch([1, 2, 3, 4, 5, 6]);
assert.ok(Math.abs(out[4] - 45) < 1e-9);
});
test('Inertia(3, 4) on a constant RVI series equals that RVI', () => {
const n = 60;
// Every bar (open, high, low, close) = (10, 11, 9, 10.5) -> RVI = 0.25.
const out = new wickra.Inertia(3, 4).batch(
Array(n).fill(10),
Array(n).fill(11),
Array(n).fill(9),
Array(n).fill(10.5),
);
for (let i = 5; i < n; i++) assert.ok(Math.abs(out[i] - 0.25) < 1e-12);
});
test('ConnorsRSI stays bounded in [0, 100]', () => {
const prices = Array.from({ length: 250 }, (_, i) => 100 + 20 * Math.sin(i * 0.12));
const out = new wickra.ConnorsRSI(3, 2, 100).batch(prices);
for (let i = 0; i < out.length; i++) {
if (Number.isNaN(out[i])) continue;
assert.ok(out[i] >= 0 && out[i] <= 100, `out[${i}] = ${out[i]}`);
}
});
test('LaguerreRSI on a flat series stays at the neutral 50', () => {
const out = new wickra.LaguerreRSI(0.5).batch(Array(40).fill(42));
for (let i = 0; i < out.length; i++) assert.ok(Math.abs(out[i] - 50) < 1e-12);
});
test('SMI with close at range centre emits zero after warmup', () => {
const n = 60;
const out = new wickra.SMI(5, 3, 3).batch(Array(n).fill(11), Array(n).fill(9), Array(n).fill(10));
// warmup_period = 5 + 3 + 3 - 2 = 9.
for (let i = 8; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12);
});
test('KST on a flat series emits zero after warmup', () => {
const kst = new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9);
const n = 80;
const out = kst.batch(Array(n).fill(42));
const warmup = kst.warmupPeriod();
for (let i = warmup - 1; i < n; i++) {
assert.ok(Math.abs(out[i * 2]) < 1e-12, `kst[${i}] = ${out[i * 2]}`);
assert.ok(Math.abs(out[i * 2 + 1]) < 1e-12, `signal[${i}] = ${out[i * 2 + 1]}`);
}
});
test('PGO(5) on a flat close emits zero after warmup', () => {
const n = 20;
const out = new wickra.PGO(5).batch(Array(n).fill(11), Array(n).fill(9), Array(n).fill(10));
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
for (let i = 4; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12, `out[${i}] = ${out[i]}`);
});
test('RVI(2) reference value on two bars', () => {
// Bars (open, high, low, close): (10, 11, 9, 10.5), (10.5, 11.5, 10, 11).
const out = new wickra.RVI(2).batch([10, 10.5], [11, 11.5], [9, 10], [10.5, 11]);
assert.ok(Number.isNaN(out[0]));
assert.ok(Math.abs(out[1] - 1 / 3.5) < 1e-12);
});
test('EVWMA(2) reference values on [10, 20, 30] with volumes [1, 3, 1]', () => {
const out = new wickra.EVWMA(2).batch([10, 20, 30], [1, 3, 1]);
assert.ok(Number.isNaN(out[0]));
assert.ok(Math.abs(out[1] - 20) < 1e-12);
assert.ok(Math.abs(out[2] - 22.5) < 1e-12);
});
test('Alligator on a flat median price seeds to that median', () => {
const n = 30;
const out = new wickra.Alligator(13, 8, 5).batch(Array(n).fill(11), Array(n).fill(9));
// All three SMMAs see median (11 + 9) / 2 = 10 every bar.
for (let i = 12; i < n; i++) {
assert.ok(Math.abs(out[i * 3] - 10) < 1e-12, `jaw at ${i}: ${out[i * 3]}`);
assert.ok(Math.abs(out[i * 3 + 1] - 10) < 1e-12);
assert.ok(Math.abs(out[i * 3 + 2] - 10) < 1e-12);
}
});
test('JMA on a flat series reproduces the constant', () => {
const out = new wickra.JMA(14, 0, 2).batch(Array(30).fill(42));
for (let i = 0; i < 30; i++) assert.ok(Math.abs(out[i] - 42) < 1e-12);
});
test('VIDYA on a flat series holds the seed', () => {
const out = new wickra.VIDYA(14, 4).batch(Array(20).fill(42));
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
for (let i = 4; i < 20; i++) assert.ok(Math.abs(out[i] - 42) < 1e-12);
});
test('FRAMA pure uptrend hugs the latest close', () => {
const out = new wickra.FRAMA(4).batch([1, 2, 3, 4, 5, 6, 7, 8]);
assert.ok(Math.abs(out[out.length - 1] - 8) < 0.05);
});
test('McGinleyDynamic(3) seeds with SMA and recurses on the next price', () => {
// Seed = SMA([10, 20, 30]) = 20. On 40: ratio = 2, divisor = 0.6*3*16 = 28.8.
const out = new wickra.McGinleyDynamic(3).batch([10, 20, 30, 40]);
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
assert.ok(Math.abs(out[2] - 20) < 1e-12);
const expected = 20 + 20 / (0.6 * 3 * 16);
assert.ok(Math.abs(out[3] - expected) < 1e-12);
});
test('ALMA(3, 0.85, 6) reference value on [10, 20, 30]', () => {
// m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5.
const out = new wickra.ALMA(3, 0.85, 6).batch([10, 20, 30]);
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
const w = [0, 1, 2].map((i) => Math.exp(-Math.pow(i - 1.7, 2) / 0.5));
const s = w[0] + w[1] + w[2];
const expected = (10 * w[0] + 20 * w[1] + 30 * w[2]) / s;
assert.ok(Math.abs(out[2] - expected) < 1e-12);
// The heavy offset toward the newest sample lifts the average above the
// simple mean of 20.
assert.ok(out[2] > 20);
});