From aeea6a89bbf86f84be8722ee2f3b29a9012c64d5 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Fri, 22 May 2026 04:07:42 +0200 Subject: [PATCH] B8: add a comprehensive Node binding test suite The Node binding had only 7 smoke cases with no streaming-vs-batch or candle-indicator coverage. Adds indicators.test.js: streaming update() matches batch() for all 25 indicators (scalar, candle-scalar and interleaved multi-output), a lifecycle check that every indicator exposes reset/isReady/warmupPeriod, and reference values (SMA(3), MFI(2)=1200/23, RSI uptrend, MACD histogram). 38 Node tests pass. --- bindings/node/__tests__/indicators.test.js | 165 +++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 bindings/node/__tests__/indicators.test.js diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js new file mode 100644 index 00000000..b88eb4aa --- /dev/null +++ b/bindings/node/__tests__/indicators.test.js @@ -0,0 +1,165 @@ +// Comprehensive tests for the Wickra Node bindings: streaming-vs-batch +// equivalence, reference values, and lifecycle methods across all 25 +// 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); + +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), +}; + +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) }, + 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) }, +}; + +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 = { + 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) }, +}; + +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); +});