From d23626cfda35ce3c9d0ca7c11ff3fa0772ad3cc8 Mon Sep 17 00:00:00 2001 From: TDamiao Date: Thu, 26 Feb 2026 14:37:18 -0300 Subject: [PATCH] feat(stateful): add streaming SMA/EMA with parity tests --- README.md | 26 +++++++++++++++-- src/index.ts | 2 +- src/stateful.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++ test/golden.test.mjs | 42 ++++++++++++++++++++++++++- 4 files changed, 135 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c950144..b6e92f0 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,13 @@ const vs = vwapSession(high, low, close, volume, [1, 1]); ## Stateful API (streaming) ```ts -import { createRSI, createVWAPSession } from "ta-crypto"; +import { createSMA, createEMA, createRSI, createVWAPSession } from "ta-crypto"; +const sma14 = createSMA(14); +const ema14 = createEMA(14); const rsi14 = createRSI(14); +const nextSma = sma14.next(101.25); +const nextEma = ema14.next(101.25); const nextRsi = rsi14.next(101.25); const vwap = createVWAPSession(); @@ -48,6 +52,24 @@ const nextVwap = vwap.next({ }); ``` +Websocket-like streaming loop: + +```ts +import { createEMA, createRSI } from "ta-crypto"; + +const ema21 = createEMA(21); +const rsi14 = createRSI(14); + +ws.on("message", (tick) => { + const price = Number(tick.last); + const e = ema21.next(price); + const r = rsi14.next(price); + if (e !== null && r !== null) { + // strategy signal path + } +}); +``` + ## Examples Real-world entry points live in `examples/`: @@ -208,7 +230,7 @@ Validation: import { sma } from "ta-crypto/indicators"; import { vwapSession } from "ta-crypto/crypto"; import { toOHLCV } from "ta-crypto/candles"; -import { createRSI } from "ta-crypto/stateful"; +import { createSMA, createEMA, createRSI } from "ta-crypto/stateful"; ``` ## Bench (internal baseline, 10k candles) diff --git a/src/index.ts b/src/index.ts index 9af11c3..623f2ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,7 +28,7 @@ export { orderflowImbalance } from "./api.js"; export { pluckOpen, pluckHigh, pluckLow, pluckClose, pluckVolume, toOHLCV } from "./candles.js"; -export { createRSI, createVWAPSession } from "./stateful.js"; +export { createSMA, createEMA, createRSI, createVWAPSession } from "./stateful.js"; export * from "./types.js"; import * as api from "./api.js"; diff --git a/src/stateful.ts b/src/stateful.ts index e45bdb6..25a9aa9 100644 --- a/src/stateful.ts +++ b/src/stateful.ts @@ -3,6 +3,75 @@ export type StatefulIndicator = { reset(): void; }; +export function createSMA(period = 14): StatefulIndicator { + if (period <= 0) { + throw new Error("period must be > 0"); + } + + const window: number[] = []; + let sum = 0; + + return { + next(value: number): number | null { + if (!Number.isFinite(value)) { + throw new Error("value must be a finite number"); + } + + window.push(value); + sum += value; + + if (window.length < period) { + return null; + } + if (window.length > period) { + sum -= window.shift() as number; + } + return sum / period; + }, + reset(): void { + window.length = 0; + sum = 0; + } + }; +} + +export function createEMA(period = 14): StatefulIndicator { + if (period <= 0) { + throw new Error("period must be > 0"); + } + + const k = 2 / (period + 1); + let seedSum = 0; + let seedCount = 0; + let prev: number | null = null; + + return { + next(value: number): number | null { + if (!Number.isFinite(value)) { + throw new Error("value must be a finite number"); + } + + if (prev === null) { + seedSum += value; + seedCount += 1; + if (seedCount < period) { + return null; + } + prev = seedSum / period; + return prev; + } + + prev = (value - prev) * k + prev; + return prev; + }, + reset(): void { + seedSum = 0; + seedCount = 0; + prev = null; + } + }; +} + export function createRSI(period = 14): StatefulIndicator { if (period <= 0) { throw new Error("period must be > 0"); diff --git a/test/golden.test.mjs b/test/golden.test.mjs index d134ab6..fc56e64 100644 --- a/test/golden.test.mjs +++ b/test/golden.test.mjs @@ -10,6 +10,8 @@ import { bbands, atr, adx, + createSMA, + createEMA, vwapSession, createRSI, createVWAPSession @@ -55,11 +57,28 @@ test("golden parity: overlap/momentum/trend/volatility/crypto", () => { approxSeries(actualADX.minusDI, golden.adx14.minusDI); }); -test("stateful parity: RSI and VWAPSession", () => { +test("stateful parity: SMA/EMA/RSI and VWAPSession", () => { + const smaState = createSMA(14); + const actualStatefulSMA = close.map(v => smaState.next(v)); + approxSeries(actualStatefulSMA, sma(close, 14)); + smaState.reset(); + const actualStatefulSMAAfterReset = close.map(v => smaState.next(v)); + approxSeries(actualStatefulSMAAfterReset, sma(close, 14)); + + const emaState = createEMA(14); + const actualStatefulEMA = close.map(v => emaState.next(v)); + approxSeries(actualStatefulEMA, ema(close, 14)); + emaState.reset(); + const actualStatefulEMAAfterReset = close.map(v => emaState.next(v)); + approxSeries(actualStatefulEMAAfterReset, ema(close, 14)); + const rsiState = createRSI(14); const actualStatefulRSI = close.map(v => rsiState.next(v)); approxSeries(actualStatefulRSI, golden.statefulRSI14); approxSeries(actualStatefulRSI, rsi(close, 14)); + rsiState.reset(); + const actualStatefulRSIAfterReset = close.map(v => rsiState.next(v)); + approxSeries(actualStatefulRSIAfterReset, rsi(close, 14)); const vwapState = createVWAPSession(); const actualStatefulVWAP = high.map((_, i) => @@ -74,4 +93,25 @@ test("stateful parity: RSI and VWAPSession", () => { approxSeries(actualStatefulVWAP, golden.statefulVWAPSession); approxSeries(actualStatefulVWAP, vwapSession(high, low, close, volume, session)); + vwapState.reset(); + const actualStatefulVWAPAfterReset = high.map((_, i) => + vwapState.next({ + high: high[i], + low: low[i], + close: close[i], + volume: volume[i], + sessionId: session[i] + }) + ); + approxSeries(actualStatefulVWAPAfterReset, vwapSession(high, low, close, volume, session)); +}); + +test("stateful API validates invalid inputs", () => { + const smaState = createSMA(14); + const emaState = createEMA(14); + const rsiState = createRSI(14); + + assert.throws(() => smaState.next(Number.NaN), /value must be a finite number/); + assert.throws(() => emaState.next(Number.POSITIVE_INFINITY), /value must be a finite number/); + assert.throws(() => rsiState.next(Number.NaN), /price must be a finite number/); });