diff --git a/README.md b/README.md index 262da88..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/`: @@ -158,20 +180,49 @@ Limitations: - Orderflow proxies infer pressure from candle direction and volume; they are not a replacement for L2/L3 order book data. - Different libraries use different warmup conventions; comparisons use overlapping non-null windows. -## Candle Contracts +## Typing and Inputs + +`ta-crypto` public APIs accept two input styles: +- Primitive arrays (`number[]`) for low-level control. +- Candle objects with long keys (`open/high/low/close/volume/time`) or aliases (`o/h/l/c/v/t`). + +Single-series APIs (for example `sma`, `ema`, `rsi`, `macd`, `bbands`) accept: + +```ts +import { rsi } from "ta-crypto"; + +const close = [101, 102, 103, 104]; +const candles = [{ o: 100, h: 102, l: 99, c: 101, v: 10, t: 1 }]; + +rsi(close, 14); +rsi(candles, 14); // uses candle close/c +``` + +OHLC/OHLCV APIs (for example `vwap`, `stoch`, `atr`, `natr`, `mfi`, `adx`) accept: + +```ts +import { atr, vwap } from "ta-crypto"; + +atr([102, 103], [99, 100], [101, 102], 14); +atr([{ open: 100, high: 102, low: 99, close: 101, volume: 10 }], 14); + +vwap([102, 103], [99, 100], [101, 102], [10, 12]); +vwap([{ o: 100, h: 102, l: 99, c: 101, v: 10 }]); +``` -Use typed candles plus helpers: +Helpers: ```ts import { pluckClose, toOHLCV } from "ta-crypto"; const close = pluckClose(candles); -const { open, high, low, close: c, volume } = toOHLCV(candles, 0); +const ohlcv = toOHLCV(candles, 0); +const ohlcvFromArrays = toOHLCV({ o: [1, 2], h: [3, 4], l: [0, 1], c: [2, 3], v: [5, 6] }); ``` Validation: -- Multi-series indicators enforce equal lengths (`assertSameLength`). -- Candle helpers validate finite numeric fields with index-specific error messages. +- Multi-series APIs enforce equal lengths. +- Numeric inputs are validated as finite numbers with index-aware messages when possible. ## Module Imports @@ -179,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/api.ts b/src/api.ts new file mode 100644 index 0000000..4628960 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,427 @@ +import type { Candle, OHLCVInput, PriceInput } from "./types.js"; +import { assertFiniteSeries } from "./core/math.js"; +import { normalizePrice, toOHLCV } from "./candles.js"; +import { + sma as coreSma, + ema as coreEma, + rma as coreRma, + hl2 as coreHl2, + hlc3 as coreHlc3, + ohlc4 as coreOhlc4, + vwap as coreVwap, + bbands as coreBbands +} from "./core/overlap.js"; +import { rsi as coreRsi, macd as coreMacd, stoch as coreStoch } from "./core/momentum.js"; +import { trueRange as coreTrueRange, atr as coreAtr, natr as coreNatr } from "./core/volatility.js"; +import { + logReturn as coreLogReturn, + percentReturn as corePercentReturn, + realizedVolatility as coreRealizedVolatility +} from "./core/performance.js"; +import { obv as coreObv, mfi as coreMfi } from "./core/volume.js"; +import { adx as coreAdx } from "./core/trend.js"; +import { + vwapSession as coreVwapSession, + fundingRateCumulative as coreFundingRateCumulative, + fundingRateAPR as coreFundingRateAPR, + volatilityRegime as coreVolatilityRegime, + signedVolume as coreSignedVolume, + volumeDelta as coreVolumeDelta, + orderflowImbalance as coreOrderflowImbalance +} from "./core/crypto.js"; + +type OHLCInput = Candle[] | OHLCVInput; + +function isNumericArray(input: unknown): input is number[] { + return Array.isArray(input) && (input.length === 0 || typeof input[0] === "number"); +} + +function parseHLC(input: number[] | OHLCInput, low?: number[], close?: number[]) { + if (isNumericArray(input)) { + if (!low || !close) { + throw new Error("Expected high, low, close arrays or candles/OHLCV object input"); + } + assertFiniteSeries("high", input); + assertFiniteSeries("low", low); + assertFiniteSeries("close", close); + return { high: input, low, close }; + } + return toOHLCV(input); +} + +function parseOHLC(input: number[] | OHLCInput, high?: number[], low?: number[], close?: number[]) { + if (isNumericArray(input)) { + if (!high || !low || !close) { + throw new Error("Expected open, high, low, close arrays or candles/OHLCV object input"); + } + assertFiniteSeries("open", input); + assertFiniteSeries("high", high); + assertFiniteSeries("low", low); + assertFiniteSeries("close", close); + return { open: input, high, low, close }; + } + return toOHLCV(input); +} + +function parseHLCV( + input: number[] | OHLCInput, + low?: number[], + close?: number[], + volume?: number[] +) { + if (isNumericArray(input)) { + if (!low || !close || !volume) { + throw new Error("Expected high, low, close, volume arrays or candles/OHLCV object input"); + } + assertFiniteSeries("high", input); + assertFiniteSeries("low", low); + assertFiniteSeries("close", close); + assertFiniteSeries("volume", volume); + return { high: input, low, close, volume }; + } + return toOHLCV(input); +} + +function parseOCV(input: number[] | OHLCInput, close?: number[], volume?: number[]) { + if (isNumericArray(input)) { + if (!close || !volume) { + throw new Error("Expected open, close, volume arrays or candles/OHLCV object input"); + } + assertFiniteSeries("open", input); + assertFiniteSeries("close", close); + assertFiniteSeries("volume", volume); + return { open: input, close, volume }; + } + return toOHLCV(input); +} + +export function sma(input: PriceInput, length = 14): Array { + return coreSma(normalizePrice(input), length); +} + +export function ema(input: PriceInput, length = 14): Array { + return coreEma(normalizePrice(input), length); +} + +export function rma(input: PriceInput, length = 14): Array { + return coreRma(normalizePrice(input), length); +} + +export function bbands(input: PriceInput, length = 20, std = 2) { + return coreBbands(normalizePrice(input), length, std); +} + +export function macd(input: PriceInput, fast = 12, slow = 26, signal = 9) { + return coreMacd(normalizePrice(input), fast, slow, signal); +} + +export function rsi(input: PriceInput, length = 14): Array { + return coreRsi(normalizePrice(input), length); +} + +export function logReturn(input: PriceInput, cumulative = false): Array { + return coreLogReturn(normalizePrice(input), cumulative); +} + +export function percentReturn(input: PriceInput, cumulative = false): Array { + return corePercentReturn(normalizePrice(input), cumulative); +} + +export function realizedVolatility(input: PriceInput, length = 30, periodsPerYear = 365): Array { + return coreRealizedVolatility(normalizePrice(input), length, periodsPerYear); +} + +export function hl2(high: number[], low: number[]): Array; +export function hl2(input: OHLCInput): Array; +export function hl2(input: number[] | OHLCInput, low?: number[]): Array { + if (isNumericArray(input)) { + if (!low) throw new Error("Expected high + low arrays or candles/OHLCV object input"); + return coreHl2(input, low); + } + const ohlcv = toOHLCV(input); + return coreHl2(ohlcv.high, ohlcv.low); +} + +export function hlc3(high: number[], low: number[], close: number[]): Array; +export function hlc3(input: OHLCInput): Array; +export function hlc3(input: number[] | OHLCInput, low?: number[], close?: number[]): Array { + const ohlcv = parseHLC(input, low, close); + return coreHlc3(ohlcv.high, ohlcv.low, ohlcv.close); +} + +export function ohlc4(open: number[], high: number[], low: number[], close: number[]): Array; +export function ohlc4(input: OHLCInput): Array; +export function ohlc4( + input: number[] | OHLCInput, + high?: number[], + low?: number[], + close?: number[] +): Array { + const ohlcv = parseOHLC(input, high, low, close); + return coreOhlc4(ohlcv.open, ohlcv.high, ohlcv.low, ohlcv.close); +} + +export function vwap( + high: number[], + low: number[], + close: number[], + volume: number[], + length?: number +): Array; +export function vwap(input: OHLCInput, length?: number): Array; +export function vwap( + input: number[] | OHLCInput, + lowOrLength?: number[] | number, + close?: number[], + volume?: number[], + length?: number +): Array { + if (isNumericArray(input)) { + const low = Array.isArray(lowOrLength) ? lowOrLength : undefined; + const resolvedLength = typeof lowOrLength === "number" ? lowOrLength : length; + const ohlcv = parseHLCV(input, low, close, volume); + return coreVwap(ohlcv.high, ohlcv.low, ohlcv.close, ohlcv.volume, resolvedLength); + } + const ohlcv = toOHLCV(input); + const resolvedLength = typeof lowOrLength === "number" ? lowOrLength : length; + return coreVwap(ohlcv.high, ohlcv.low, ohlcv.close, ohlcv.volume, resolvedLength); +} + +export function stoch( + high: number[], + low: number[], + close: number[], + kLength?: number, + dLength?: number +): { k: Array; d: Array }; +export function stoch(input: OHLCInput, kLength?: number, dLength?: number): { k: Array; d: Array }; +export function stoch( + input: number[] | OHLCInput, + lowOrKLength?: number[] | number, + closeOrDLength?: number[] | number, + kLength = 14, + dLength = 3 +) { + if (isNumericArray(input)) { + const low = Array.isArray(lowOrKLength) ? lowOrKLength : undefined; + const close = Array.isArray(closeOrDLength) ? closeOrDLength : undefined; + const resolvedK = typeof lowOrKLength === "number" ? lowOrKLength : kLength; + const resolvedD = typeof closeOrDLength === "number" ? closeOrDLength : dLength; + const ohlcv = parseHLC(input, low, close); + return coreStoch(ohlcv.high, ohlcv.low, ohlcv.close, resolvedK, resolvedD); + } + const resolvedK = typeof lowOrKLength === "number" ? lowOrKLength : kLength; + const resolvedD = typeof closeOrDLength === "number" ? closeOrDLength : dLength; + const ohlcv = toOHLCV(input); + return coreStoch(ohlcv.high, ohlcv.low, ohlcv.close, resolvedK, resolvedD); +} + +export function trueRange(high: number[], low: number[], close: number[]): Array; +export function trueRange(input: OHLCInput): Array; +export function trueRange(input: number[] | OHLCInput, low?: number[], close?: number[]): Array { + const ohlcv = parseHLC(input, low, close); + return coreTrueRange(ohlcv.high, ohlcv.low, ohlcv.close); +} + +export function atr(high: number[], low: number[], close: number[], length?: number): Array; +export function atr(input: OHLCInput, length?: number): Array; +export function atr( + input: number[] | OHLCInput, + lowOrLength?: number[] | number, + close?: number[], + length = 14 +): Array { + if (isNumericArray(input)) { + const low = Array.isArray(lowOrLength) ? lowOrLength : undefined; + const resolvedLength = typeof lowOrLength === "number" ? lowOrLength : length; + const ohlcv = parseHLC(input, low, close); + return coreAtr(ohlcv.high, ohlcv.low, ohlcv.close, resolvedLength); + } + const ohlcv = toOHLCV(input); + const resolvedLength = typeof lowOrLength === "number" ? lowOrLength : length; + return coreAtr(ohlcv.high, ohlcv.low, ohlcv.close, resolvedLength); +} + +export function natr(high: number[], low: number[], close: number[], length?: number): Array; +export function natr(input: OHLCInput, length?: number): Array; +export function natr( + input: number[] | OHLCInput, + lowOrLength?: number[] | number, + close?: number[], + length = 14 +): Array { + if (isNumericArray(input)) { + const low = Array.isArray(lowOrLength) ? lowOrLength : undefined; + const resolvedLength = typeof lowOrLength === "number" ? lowOrLength : length; + const ohlcv = parseHLC(input, low, close); + return coreNatr(ohlcv.high, ohlcv.low, ohlcv.close, resolvedLength); + } + const ohlcv = toOHLCV(input); + const resolvedLength = typeof lowOrLength === "number" ? lowOrLength : length; + return coreNatr(ohlcv.high, ohlcv.low, ohlcv.close, resolvedLength); +} + +export function obv(close: number[], volume: number[]): Array; +export function obv(input: OHLCInput): Array; +export function obv(input: number[] | OHLCInput, volume?: number[]): Array { + if (isNumericArray(input)) { + if (!volume) throw new Error("Expected close + volume arrays or candles/OHLCV object input"); + return coreObv(input, volume); + } + const ohlcv = toOHLCV(input); + return coreObv(ohlcv.close, ohlcv.volume); +} + +export function mfi( + high: number[], + low: number[], + close: number[], + volume: number[], + length?: number +): Array; +export function mfi(input: OHLCInput, length?: number): Array; +export function mfi( + input: number[] | OHLCInput, + lowOrLength?: number[] | number, + close?: number[], + volume?: number[], + length = 14 +): Array { + if (isNumericArray(input)) { + const low = Array.isArray(lowOrLength) ? lowOrLength : undefined; + const resolvedLength = typeof lowOrLength === "number" ? lowOrLength : length; + const ohlcv = parseHLCV(input, low, close, volume); + return coreMfi(ohlcv.high, ohlcv.low, ohlcv.close, ohlcv.volume, resolvedLength); + } + const ohlcv = toOHLCV(input); + const resolvedLength = typeof lowOrLength === "number" ? lowOrLength : length; + return coreMfi(ohlcv.high, ohlcv.low, ohlcv.close, ohlcv.volume, resolvedLength); +} + +export function adx( + high: number[], + low: number[], + close: number[], + length?: number +): { adx: Array; plusDI: Array; minusDI: Array }; +export function adx( + input: OHLCInput, + length?: number +): { adx: Array; plusDI: Array; minusDI: Array }; +export function adx( + input: number[] | OHLCInput, + lowOrLength?: number[] | number, + close?: number[], + length = 14 +): { adx: Array; plusDI: Array; minusDI: Array } { + if (isNumericArray(input)) { + const low = Array.isArray(lowOrLength) ? lowOrLength : undefined; + const resolvedLength = typeof lowOrLength === "number" ? lowOrLength : length; + const ohlcv = parseHLC(input, low, close); + return coreAdx(ohlcv.high, ohlcv.low, ohlcv.close, resolvedLength); + } + const ohlcv = toOHLCV(input); + const resolvedLength = typeof lowOrLength === "number" ? lowOrLength : length; + return coreAdx(ohlcv.high, ohlcv.low, ohlcv.close, resolvedLength); +} + +export function vwapSession( + high: number[], + low: number[], + close: number[], + volume: number[], + session: Array +): Array; +export function vwapSession(input: OHLCInput, session: Array): Array; +export function vwapSession( + input: number[] | OHLCInput, + lowOrSession: number[] | Array, + close?: number[], + volume?: number[], + session?: Array +): Array { + if (isNumericArray(input)) { + if (!isNumericArray(lowOrSession)) { + throw new Error("Expected high, low, close, volume, session arrays"); + } + if (!session || !close || !volume) { + throw new Error("Expected high, low, close, volume, session arrays"); + } + return coreVwapSession(input, lowOrSession, close, volume, session); + } + if (!Array.isArray(lowOrSession)) { + throw new Error("session must be an array"); + } + const ohlcv = toOHLCV(input); + return coreVwapSession(ohlcv.high, ohlcv.low, ohlcv.close, ohlcv.volume, lowOrSession as Array); +} + +export function signedVolume(open: number[], close: number[], volume: number[]): Array; +export function signedVolume(input: OHLCInput): Array; +export function signedVolume(input: number[] | OHLCInput, close?: number[], volume?: number[]): Array { + const ohlcv = parseOCV(input, close, volume); + return coreSignedVolume(ohlcv.open, ohlcv.close, ohlcv.volume); +} + +export function volumeDelta(open: number[], close: number[], volume: number[], length?: number): Array; +export function volumeDelta(input: OHLCInput, length?: number): Array; +export function volumeDelta( + input: number[] | OHLCInput, + closeOrLength?: number[] | number, + volume?: number[], + length = 14 +): Array { + if (isNumericArray(input)) { + const close = Array.isArray(closeOrLength) ? closeOrLength : undefined; + const resolvedLength = typeof closeOrLength === "number" ? closeOrLength : length; + const ohlcv = parseOCV(input, close, volume); + return coreVolumeDelta(ohlcv.open, ohlcv.close, ohlcv.volume, resolvedLength); + } + const ohlcv = toOHLCV(input); + const resolvedLength = typeof closeOrLength === "number" ? closeOrLength : length; + return coreVolumeDelta(ohlcv.open, ohlcv.close, ohlcv.volume, resolvedLength); +} + +export function orderflowImbalance( + open: number[], + close: number[], + volume: number[], + length?: number +): Array; +export function orderflowImbalance(input: OHLCInput, length?: number): Array; +export function orderflowImbalance( + input: number[] | OHLCInput, + closeOrLength?: number[] | number, + volume?: number[], + length = 14 +): Array { + if (isNumericArray(input)) { + const close = Array.isArray(closeOrLength) ? closeOrLength : undefined; + const resolvedLength = typeof closeOrLength === "number" ? closeOrLength : length; + const ohlcv = parseOCV(input, close, volume); + return coreOrderflowImbalance(ohlcv.open, ohlcv.close, ohlcv.volume, resolvedLength); + } + const ohlcv = toOHLCV(input); + const resolvedLength = typeof closeOrLength === "number" ? closeOrLength : length; + return coreOrderflowImbalance(ohlcv.open, ohlcv.close, ohlcv.volume, resolvedLength); +} + +export function fundingRateCumulative(values: number[]): Array { + assertFiniteSeries("values", values); + return coreFundingRateCumulative(values); +} + +export function fundingRateAPR(values: number[], periodsPerYear = 365 * 3): Array { + assertFiniteSeries("values", values); + return coreFundingRateAPR(values, periodsPerYear); +} + +export function volatilityRegime( + input: PriceInput, + length = 30, + periodsPerYear = 365, + lowZ = -0.5, + highZ = 0.5 +): Array { + return coreVolatilityRegime(normalizePrice(input), length, periodsPerYear, lowZ, highZ); +} diff --git a/src/candles.ts b/src/candles.ts index 7e9293a..bbaddff 100644 --- a/src/candles.ts +++ b/src/candles.ts @@ -1,52 +1,112 @@ -import type { Candle } from "./types.js"; -import { assertFiniteSeries } from "./core/math.js"; - -export type OHLCV = { - open: number[]; - high: number[]; - low: number[]; - close: number[]; - volume: number[]; - time: Array; -}; +import { assertFiniteSeries, assertSameLength, isNum } from "./core/math.js"; +import type { Candle, OHLCV, OHLCVInput, PriceInput, TimeValue } from "./types.js"; + +function readField(candle: Candle, index: number, name: string, alias: string): number { + const value = (candle as Record)[name] ?? (candle as Record)[alias]; + if (!isNum(value)) { + throw new Error(`candles[${index}].${name} (or .${alias}) must be a finite number`); + } + return value; +} + +function readTime(candle: Candle): TimeValue | undefined { + const value = (candle as Record).time ?? (candle as Record).t; + return value as TimeValue | undefined; +} + +function hasOHLCVArrays(input: OHLCVInput): input is OHLCV { + return "open" in input && "high" in input && "low" in input && "close" in input; +} + +export function isCandleArray(input: PriceInput | OHLCVInput): input is Candle[] { + return Array.isArray(input) && input.length > 0 && typeof input[0] !== "number"; +} + +export function normalizePrice(input: PriceInput, name = "values"): number[] { + if (Array.isArray(input) && (input.length === 0 || typeof input[0] === "number")) { + assertFiniteSeries(name, input as number[]); + return input as number[]; + } + return pluckClose(input as Candle[]); +} export function pluckOpen(candles: Candle[]): number[] { - const out = candles.map(c => c.open); + const out = candles.map((candle, index) => readField(candle, index, "open", "o")); assertFiniteSeries("open", out); return out; } export function pluckHigh(candles: Candle[]): number[] { - const out = candles.map(c => c.high); + const out = candles.map((candle, index) => readField(candle, index, "high", "h")); assertFiniteSeries("high", out); return out; } export function pluckLow(candles: Candle[]): number[] { - const out = candles.map(c => c.low); + const out = candles.map((candle, index) => readField(candle, index, "low", "l")); assertFiniteSeries("low", out); return out; } export function pluckClose(candles: Candle[]): number[] { - const out = candles.map(c => c.close); + const out = candles.map((candle, index) => readField(candle, index, "close", "c")); assertFiniteSeries("close", out); return out; } export function pluckVolume(candles: Candle[], fallback = 0): number[] { - const out = candles.map(c => (c.volume === undefined ? fallback : c.volume)); + if (!isNum(fallback)) { + throw new Error("volumeFallback must be a finite number"); + } + const out = candles.map((candle, index) => { + const value = (candle as Record).volume ?? (candle as Record).v; + const normalized = value === undefined ? fallback : value; + if (!isNum(normalized)) { + throw new Error(`candles[${index}].volume (or .v) must be a finite number`); + } + return normalized; + }); assertFiniteSeries("volume", out); return out; } -export function toOHLCV(candles: Candle[], volumeFallback = 0): OHLCV { - return { - open: pluckOpen(candles), - high: pluckHigh(candles), - low: pluckLow(candles), - close: pluckClose(candles), - volume: pluckVolume(candles, volumeFallback), - time: candles.map(c => c.time) - }; +export function toOHLCV(input: Candle[] | OHLCVInput, volumeFallback = 0): OHLCV { + if (Array.isArray(input)) { + return { + open: pluckOpen(input), + high: pluckHigh(input), + low: pluckLow(input), + close: pluckClose(input), + volume: pluckVolume(input, volumeFallback), + time: input.map(c => readTime(c)) + }; + } + + if (hasOHLCVArrays(input)) { + assertFiniteSeries("open", input.open); + assertFiniteSeries("high", input.high); + assertFiniteSeries("low", input.low); + assertFiniteSeries("close", input.close); + const volume = input.volume ?? new Array(input.close.length).fill(volumeFallback); + assertFiniteSeries("volume", volume); + assertSameLength(input.open, input.high, input.low, input.close, volume); + const time = input.time ?? new Array(input.close.length).fill(undefined); + if (time.length !== input.close.length) { + throw new Error(`All series must have the same length (expected ${input.close.length}, got ${time.length})`); + } + return { open: input.open, high: input.high, low: input.low, close: input.close, volume, time }; + } + + assertFiniteSeries("open", input.o); + assertFiniteSeries("high", input.h); + assertFiniteSeries("low", input.l); + assertFiniteSeries("close", input.c); + const volume = input.v ?? new Array(input.c.length).fill(volumeFallback); + assertFiniteSeries("volume", volume); + assertSameLength(input.o, input.h, input.l, input.c, volume); + const time = input.t ?? new Array(input.c.length).fill(undefined); + if (time.length !== input.c.length) { + throw new Error(`All series must have the same length (expected ${input.c.length}, got ${time.length})`); + } + return { open: input.o, high: input.h, low: input.l, close: input.c, volume, time }; } diff --git a/src/crypto.ts b/src/crypto.ts index a88b28e..cc328f8 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -6,5 +6,5 @@ export { signedVolume, volumeDelta, orderflowImbalance -} from "./core/crypto.js"; +} from "./api.js"; export { createVWAPSession } from "./stateful.js"; diff --git a/src/index.ts b/src/index.ts index 559e9e6..623f2ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,24 @@ -export { sma, ema, rma, hl2, hlc3, ohlc4, vwap, bbands } from "./core/overlap.js"; -export { rsi, macd, stoch } from "./core/momentum.js"; -export { trueRange, atr, natr } from "./core/volatility.js"; -export { logReturn, percentReturn, realizedVolatility } from "./core/performance.js"; -export { obv, mfi } from "./core/volume.js"; -export { adx } from "./core/trend.js"; export { + sma, + ema, + rma, + hl2, + hlc3, + ohlc4, + vwap, + bbands, + rsi, + macd, + stoch, + trueRange, + atr, + natr, + logReturn, + percentReturn, + realizedVolatility, + obv, + mfi, + adx, vwapSession, fundingRateCumulative, fundingRateAPR, @@ -12,29 +26,17 @@ export { signedVolume, volumeDelta, orderflowImbalance -} from "./core/crypto.js"; +} 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 overlap from "./core/overlap.js"; -import * as momentum from "./core/momentum.js"; -import * as volatility from "./core/volatility.js"; -import * as performance from "./core/performance.js"; -import * as volume from "./core/volume.js"; -import * as trend from "./core/trend.js"; -import * as crypto from "./core/crypto.js"; +import * as api from "./api.js"; import * as candles from "./candles.js"; import * as stateful from "./stateful.js"; export const ta = { - ...overlap, - ...momentum, - ...volatility, - ...performance, - ...volume, - ...trend, - ...crypto, + ...api, ...candles, ...stateful }; diff --git a/src/indicators.ts b/src/indicators.ts index 8c8fb1b..783a937 100644 --- a/src/indicators.ts +++ b/src/indicators.ts @@ -1,6 +1,22 @@ -export { sma, ema, rma, hl2, hlc3, ohlc4, vwap, bbands } from "./core/overlap.js"; -export { rsi, macd, stoch } from "./core/momentum.js"; -export { trueRange, atr, natr } from "./core/volatility.js"; -export { logReturn, percentReturn, realizedVolatility } from "./core/performance.js"; -export { obv, mfi } from "./core/volume.js"; -export { adx } from "./core/trend.js"; +export { + sma, + ema, + rma, + hl2, + hlc3, + ohlc4, + vwap, + bbands, + rsi, + macd, + stoch, + trueRange, + atr, + natr, + logReturn, + percentReturn, + realizedVolatility, + obv, + mfi, + adx +} 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/src/types.ts b/src/types.ts index dc828bf..9107936 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,12 +1,44 @@ -export type NumericSeries = number[]; - +export type NumericSeries = number[]; export type Series = Array; +export type TimeValue = number | string | Date; -export type Candle = { - time?: number | string | Date; +export type CandleObject = { open: number; high: number; low: number; close: number; volume?: number; + time?: TimeValue; +}; + +export type CandleAlias = { + o: number; + h: number; + l: number; + c: number; + v?: number; + t?: TimeValue; }; + +export type Candle = CandleObject | CandleAlias; + +export type OHLCV = { + open: number[]; + high: number[]; + low: number[]; + close: number[]; + volume: number[]; + time: Array; +}; + +export type OHLCVAlias = { + o: number[]; + h: number[]; + l: number[]; + c: number[]; + v?: number[]; + t?: Array; +}; + +export type OHLCVInput = OHLCV | OHLCVAlias; +export type PriceInput = NumericSeries | Candle[]; diff --git a/test/contracts.test.mjs b/test/contracts.test.mjs index 284c40e..bcc42d7 100644 --- a/test/contracts.test.mjs +++ b/test/contracts.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { pluckClose, pluckVolume, toOHLCV, vwap } from "../dist/index.js"; +import { atr, pluckClose, pluckVolume, rsi, sma, toOHLCV, vwap } from "../dist/index.js"; const candles = [ { open: 100, high: 102, low: 99, close: 101, volume: 10, time: 1 }, @@ -8,6 +8,12 @@ const candles = [ { open: 102, high: 104, low: 101, close: 103, time: 3 } ]; +const candlesAlias = [ + { o: 100, h: 102, l: 99, c: 101, v: 10, t: 1 }, + { o: 101, h: 103, l: 100, c: 102, v: 12, t: 2 }, + { o: 102, h: 104, l: 101, c: 103, v: 8, t: 3 } +]; + test("candles helpers produce typed OHLCV arrays", () => { assert.deepEqual(pluckClose(candles), [101, 102, 103]); assert.deepEqual(pluckVolume(candles, 0), [10, 12, 0]); @@ -20,10 +26,42 @@ test("candles helpers produce typed OHLCV arrays", () => { assert.deepEqual(ohlcv.volume, [10, 12, 0]); }); +test("candles helpers accept alias fields and array-based OHLCV inputs", () => { + assert.deepEqual(pluckClose(candlesAlias), [101, 102, 103]); + assert.deepEqual(pluckVolume(candlesAlias), [10, 12, 8]); + + const byAliases = toOHLCV(candlesAlias, 0); + assert.deepEqual(byAliases.open, [100, 101, 102]); + assert.deepEqual(byAliases.high, [102, 103, 104]); + assert.deepEqual(byAliases.low, [99, 100, 101]); + assert.deepEqual(byAliases.close, [101, 102, 103]); + assert.deepEqual(byAliases.volume, [10, 12, 8]); + assert.deepEqual(byAliases.time, [1, 2, 3]); + + const byArrays = toOHLCV({ o: [1, 2], h: [3, 4], l: [0, 1], c: [2, 3], v: [5, 6], t: ["a", "b"] }); + assert.deepEqual(byArrays.open, [1, 2]); + assert.deepEqual(byArrays.volume, [5, 6]); + assert.deepEqual(byArrays.time, ["a", "b"]); +}); + +test("main APIs support both primitive arrays and candle objects", () => { + const close = [101, 102, 103]; + const high = [102, 103, 104]; + const low = [99, 100, 101]; + const volume = [10, 12, 8]; + + assert.deepEqual(sma(close, 2), sma(candlesAlias, 2)); + assert.deepEqual(rsi(close, 2), rsi(candlesAlias, 2)); + assert.deepEqual(vwap(high, low, close, volume), vwap(candlesAlias)); + assert.deepEqual(atr(high, low, close, 2), atr(candlesAlias, 2)); +}); + test("length and numeric validations return actionable messages", () => { assert.throws( () => vwap([1, 2, 3], [1, 2], [1, 2, 3], [10, 20, 30]), /All series must have the same length/ ); + assert.throws(() => vwap([1, 2, 3]), /Expected high, low, close, volume arrays or candles\/OHLCV object input/); assert.throws(() => pluckClose([{ open: 1, high: 2, low: 0, close: Number.NaN }]), /must be a finite number/); + assert.throws(() => pluckClose([{ o: 1, h: 2, l: 0, c: Number.NaN }]), /candles\[0\]\.close \(or \.c\) must be a finite number/); }); 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/); });