From 841f8024cca364debe336dcfd060f12948c97581 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 10 Nov 2026 20:03:48 +0000 Subject: [PATCH] fix: guard indicator params and WASM kernels Clamp indicator params at the API, ignore non-finite indicator outputs when autoscaling, and return NaN from sma/ema when period is below 1 instead of trapping. visibleMinMax skips the SIMD kernel on non-finite windows. Co-authored-by: Joe Blau --- packages/chart/src/core/chart.ts | 15 ++- packages/chart/src/core/scale.ts | 14 +- packages/chart/src/indicators/adx.ts | 3 +- packages/chart/src/indicators/boll.ts | 5 +- packages/chart/src/indicators/cci.ts | 3 +- packages/chart/src/indicators/ema.ts | 5 +- packages/chart/src/indicators/kdj.ts | 5 +- packages/chart/src/indicators/macd.ts | 15 ++- packages/chart/src/indicators/mfi.ts | 9 +- packages/chart/src/indicators/params.ts | 28 ++++ packages/chart/src/indicators/rsi.ts | 5 +- packages/chart/src/indicators/sma.ts | 7 +- packages/chart/src/render/indicator-draw.ts | 3 +- packages/chart/src/wasm/loader.ts | 18 ++- .../test/indicator-compute-parity.test.ts | 11 +- .../chart/test/indicator-invariants.test.ts | 125 ++++++++++++++++++ packages/chart/test/indicators-dialog.test.ts | 9 +- .../chart/test/indicators-studies.test.ts | 12 +- 18 files changed, 246 insertions(+), 46 deletions(-) create mode 100644 packages/chart/test/indicator-invariants.test.ts diff --git a/packages/chart/src/core/chart.ts b/packages/chart/src/core/chart.ts index 24a0e25..8ca3407 100644 --- a/packages/chart/src/core/chart.ts +++ b/packages/chart/src/core/chart.ts @@ -34,6 +34,7 @@ import { createSeriesRegistry, SeriesRegistry } from '../series/registry.js'; import type { SeriesDrawFn } from '../series/types.js'; import { splitPrimitives, type PanePrimitive } from '../render/primitive.js'; import { resolveIndicatorColors, type IndicatorDef, type IndicatorOutput } from '../indicators/types.js'; +import { normalizeIndicatorParams } from '../indicators/params.js'; import { indicatorLineLabels } from '../indicators/labels.js'; import { applyIndicatorPatch, normalizeIndicatorConfig, styleIndicatorOutput, type IndicatorPatch, type ResolvedIndicatorConfig, @@ -421,6 +422,7 @@ export class Chart { this.ready = Promise.resolve(this); } this.assertConfigIds(); + this.normalizeConfiguredIndicators(); this.render(); if (options.autoResize === true) this.startAutoResize(); } @@ -607,6 +609,7 @@ export class Chart { if (partial.data !== undefined || partial.timeScale !== undefined) { this.timeSlots.sync(this.store.raw(), this.config.timeScale, partial.data !== undefined ? 'data' : 'config'); } + if (partial.indicators !== undefined) this.normalizeConfiguredIndicators(); if (partial.inlineBook !== undefined) this.syncInlineBook(); if (partial.heatmap !== undefined) this.syncHeatmap(); this.render(); @@ -688,6 +691,14 @@ export class Chart { if (this.batchDepth === 0) this.events.flush(); } + /** Clamps configured indicator params to each definition's declared inputs. */ + private normalizeConfiguredIndicators(): void { + for (const ind of this.config.indicators) { + const def = this.indicators.get(ind.name); + if (def !== undefined) ind.params = normalizeIndicatorParams(def, ind.params); + } + } + /** Rejects duplicate ids and indicator ids that share the pane namespace. */ private assertConfigIds(): void { this.assertIndicatorList(this.config.indicators); @@ -735,7 +746,7 @@ export class Chart { const cfg: IndicatorConfig = { id, name: def.name, - params: { ...def.defaultParams, ...input.params }, + params: normalizeIndicatorParams(def, { ...def.defaultParams, ...input.params }), pane: input.pane ?? def.defaultPane, colors: input.colors ?? [...def.defaultColors], visible: input.visible ?? true, @@ -763,6 +774,8 @@ export class Chart { const cfg = this.getIndicator(id); if (cfg === undefined) return false; applyIndicatorPatch(cfg, patch); + const def = this.indicators.get(cfg.name); + if (def !== undefined) cfg.params = normalizeIndicatorParams(def, cfg.params); this.events.configChanged('indicators'); this.render(); return true; diff --git a/packages/chart/src/core/scale.ts b/packages/chart/src/core/scale.ts index f6dba32..ad48a8f 100644 --- a/packages/chart/src/core/scale.ts +++ b/packages/chart/src/core/scale.ts @@ -384,7 +384,9 @@ export function visibleMinMax( const start = Math.max(0, from); const end = Math.min(candles.length, to); if (end <= start) return { min: 0, max: 1 }; - if (kernels !== null) { + // The SIMD kernel propagates NaN. Use it only when this window is finite; + // otherwise the scalar loop below skips non-finite highs and lows. + if (kernels !== null && windowIsFinite(candles, start, end)) { const n = end - start; const buf = new Float32Array(n * 2); for (let i = start; i < end; i++) { @@ -404,6 +406,14 @@ export function visibleMinMax( return { min, max }; } +function windowIsFinite(candles: readonly Candle[], start: number, end: number): boolean { + for (let i = start; i < end; i++) { + const candle = candles[i]!; + if (!Number.isFinite(candle.low) || !Number.isFinite(candle.high)) return false; + } + return true; +} + /** Min/max over a sparse indicator value series within `[from, to)`. */ export function seriesMinMax( values: readonly (number | null)[], @@ -417,7 +427,7 @@ export function seriesMinMax( let seen = false; for (let i = start; i < end; i++) { const v = values[i]; - if (v === null || Number.isNaN(v)) continue; + if (v === null || !Number.isFinite(v)) continue; seen = true; if (v < min) min = v; if (v > max) max = v; diff --git a/packages/chart/src/indicators/adx.ts b/packages/chart/src/indicators/adx.ts index f31f354..9807ebb 100644 --- a/packages/chart/src/indicators/adx.ts +++ b/packages/chart/src/indicators/adx.ts @@ -8,6 +8,7 @@ import type { Candle } from '../core/data.js'; import type { IndicatorDef, IndicatorOutput } from './types.js'; import type { StudyMeta } from './study-meta.js'; +import { lengthParam } from './params.js'; /** Running state after some prefix of candles. */ interface AdxState { @@ -94,7 +95,7 @@ function runAdx( } function lengths(params: Record): [diLength: number, adxSmoothing: number] { - return [Math.max(1, Math.floor(params['diLength'] ?? 14)), Math.max(1, Math.floor(params['adxSmoothing'] ?? 14))]; + return [lengthParam(params, 'diLength', 14), lengthParam(params, 'adxSmoothing', 14)]; } /** diff --git a/packages/chart/src/indicators/boll.ts b/packages/chart/src/indicators/boll.ts index 316bdfb..f00dba1 100644 --- a/packages/chart/src/indicators/boll.ts +++ b/packages/chart/src/indicators/boll.ts @@ -8,6 +8,7 @@ import type { Candle } from '../core/data.js'; import type { IndicatorDef } from './types.js'; import { smallTail } from './tail.js'; import { smaValues, updateSma } from './sma.js'; +import { lengthParam } from './params.js'; /** * Population standard deviation in O(length) time and O(period) scratch space. @@ -78,7 +79,7 @@ export const bollIndicator: IndicatorDef = { { key: 'lower', label: 'Lower', colorIndex: 2 }, ], update(output, candles, from, params) { - const period = Math.max(1, Math.floor(params['period'] ?? 20)); + const period = lengthParam(params, 'period', 20); if (!Number.isFinite(period) || !smallTail(candles.length, from, period)) return undefined; const [mid, upper, lower] = output.lines.map(line => line.values as (number | null)[]); if (!updateSma(mid, candles, from, period)) return undefined; @@ -96,7 +97,7 @@ export const bollIndicator: IndicatorDef = { return output; }, compute(candles: readonly Candle[], params: Record, colors: readonly string[]) { - const period = Math.max(1, Math.floor(params['period'] ?? 20)); + const period = lengthParam(params, 'period', 20); const mult = params['mult'] ?? 2; const closes = candles.map((c) => c.close); const mid = smaValues(closes, period); diff --git a/packages/chart/src/indicators/cci.ts b/packages/chart/src/indicators/cci.ts index 989d694..3f8fd9a 100644 --- a/packages/chart/src/indicators/cci.ts +++ b/packages/chart/src/indicators/cci.ts @@ -9,6 +9,7 @@ import type { IndicatorDef, IndicatorLevel } from './types.js'; import type { StudyMeta } from './study-meta.js'; import { typicalPrice } from './candle-values.js'; import { windowSums } from './window-sums.js'; +import { lengthParam } from './params.js'; /** * CCI of the window of `prices` ending at `end` given its mean, or null on a @@ -43,7 +44,7 @@ function fillCci(candles: readonly Candle[], period: number, values: (number | n } function periodParam(params: Record): number { - return Math.max(1, Math.floor(params['period'] ?? 20)); + return lengthParam(params, 'period', 20); } /** diff --git a/packages/chart/src/indicators/ema.ts b/packages/chart/src/indicators/ema.ts index b228bb3..c728505 100644 --- a/packages/chart/src/indicators/ema.ts +++ b/packages/chart/src/indicators/ema.ts @@ -8,6 +8,7 @@ import type { Candle } from '../core/data.js'; import type { IndicatorDef } from './types.js'; import { TailState, smallTail } from './tail.js'; import type { WasmKernels } from '../wasm/loader.js'; +import { lengthParam } from './params.js'; const wasmStates = new WeakMap>(); @@ -77,7 +78,7 @@ export const emaIndicator: IndicatorDef = { styles: [{ key: 'value', label: 'EMA', colorIndex: 0 }], update(output, candles, from, params, _colors, kernels) { if (!smallTail(candles.length, from)) return undefined; - const period = Math.max(1, Math.floor(params['period'] ?? 20)); + const period = lengthParam(params, 'period', 20); if (kernels !== null) { const out = output.lines[0].values as (number | null)[]; const state = wasmStates.get(out); @@ -102,7 +103,7 @@ export const emaIndicator: IndicatorDef = { colors: readonly string[], kernels: WasmKernels | null, ) { - const period = Math.max(1, Math.floor(params['period'] ?? 20)); + const period = lengthParam(params, 'period', 20); let values: (number | null)[]; if (kernels !== null) { const closes = new Float32Array(candles.length); diff --git a/packages/chart/src/indicators/kdj.ts b/packages/chart/src/indicators/kdj.ts index a3324fb..a3d9728 100644 --- a/packages/chart/src/indicators/kdj.ts +++ b/packages/chart/src/indicators/kdj.ts @@ -6,6 +6,7 @@ import type { Candle } from '../core/data.js'; import type { IndicatorDef } from './types.js'; +import { lengthParam } from './params.js'; /** * Classic KDJ. RSV = (close − lowestLow(n)) / (highestHigh(n) − lowestLow(n)) × 100 @@ -71,7 +72,7 @@ export const kdjIndicator: IndicatorDef = { { key: 'j', label: 'J', colorIndex: 2 }, ], update(output, candles, from, params) { - const period = Math.max(1, Math.floor(params['period'] ?? 9)); + const period = lengthParam(params, 'period', 9); // Large batches/periods are cheaper with the linear monotonic-queue pass. if (!Number.isFinite(period) || (candles.length - from) * period > candles.length * 2) return undefined; const [k, d, j] = output.lines.map(line => line.values as (number | null)[]); @@ -92,7 +93,7 @@ export const kdjIndicator: IndicatorDef = { return output; }, compute(candles: readonly Candle[], params: Record, colors: readonly string[]) { - const period = Math.max(1, Math.floor(params['period'] ?? 9)); + const period = lengthParam(params, 'period', 9); const { k, d, j } = kdjValues(candles, period); return { pane: 'sub', diff --git a/packages/chart/src/indicators/macd.ts b/packages/chart/src/indicators/macd.ts index 48cefc6..f351b8b 100644 --- a/packages/chart/src/indicators/macd.ts +++ b/packages/chart/src/indicators/macd.ts @@ -8,6 +8,7 @@ import type { Candle } from '../core/data.js'; import { DOWN_COLOR, UP_COLOR, type IndicatorDef, type IndicatorOutput } from './types.js'; import { TailState, smallTail } from './tail.js'; import { emaValues } from './ema.js'; +import { lengthParam } from './params.js'; const states = new WeakMap>(); @@ -32,11 +33,11 @@ export const macdIndicator: IndicatorDef = { ], update(output, candles, from, params) { const state = states.get(output); - const fast = Math.max(1, Math.floor(params['fast'] ?? 12)); - const slow = Math.max(fast + 1, Math.floor(params['slow'] ?? 26)); - const signal = Math.max(1, Math.floor(params['signal'] ?? 9)); + const fast = lengthParam(params, 'fast', 12); + const slow = Math.max(fast + 1, lengthParam(params, 'slow', 26)); + const signal = lengthParam(params, 'signal', 9); const snapshot = state?.get(from - 1); - if (!smallTail(candles.length, from) || snapshot === undefined || !Number.isFinite(signal)) return undefined; + if (!smallTail(candles.length, from) || snapshot === undefined) return undefined; let [previousFast, previousSlow, previous] = snapshot; const fastWeight = 2 / (fast + 1), slowWeight = 2 / (slow + 1); const [dif, dea] = output.lines.map(line => line.values as (number | null)[]); @@ -59,9 +60,9 @@ export const macdIndicator: IndicatorDef = { return output; }, compute(candles: readonly Candle[], params: Record, colors: readonly string[]) { - const fast = Math.max(1, Math.floor(params['fast'] ?? 12)); - const slow = Math.max(fast + 1, Math.floor(params['slow'] ?? 26)); - const signal = Math.max(1, Math.floor(params['signal'] ?? 9)); + const fast = lengthParam(params, 'fast', 12); + const slow = Math.max(fast + 1, lengthParam(params, 'slow', 26)); + const signal = lengthParam(params, 'signal', 9); const closes = candles.map((c) => c.close); const emaFast = emaValues(closes, fast); const emaSlow = emaValues(closes, slow); diff --git a/packages/chart/src/indicators/mfi.ts b/packages/chart/src/indicators/mfi.ts index aa74078..66fb2b4 100644 --- a/packages/chart/src/indicators/mfi.ts +++ b/packages/chart/src/indicators/mfi.ts @@ -10,6 +10,7 @@ import type { IndicatorDef, IndicatorLevel } from './types.js'; import type { StudyMeta } from './study-meta.js'; import { candleVolume, typicalPrice } from './candle-values.js'; import { windowSums } from './window-sums.js'; +import { lengthParam } from './params.js'; /** * Writes MFI from index `from`. A flow is `volume · tp`, positive when tp @@ -17,13 +18,9 @@ import { windowSums } from './window-sums.js'; * the first candle (no previous tp) counts both ways and windows are partial * until `period` candles exist, so values start at 50 on the first candle. No * negative flow in the window gives 100 (TradingView's rsi), including a - * window without any flow. Non-finite periods leave every value null. + * window without any flow. */ function fillMfi(candles: readonly Candle[], period: number, values: (number | null)[], from: number): void { - if (!Number.isFinite(period)) { - for (let i = from; i < candles.length; i++) values[i] = null; - return; - } const base = Math.max(0, from - period + 1); const up = new Float64Array(candles.length - base); const down = new Float64Array(candles.length - base); @@ -45,7 +42,7 @@ function fillMfi(candles: readonly Candle[], period: number, values: (number | n } function periodParam(params: Record): number { - return Math.max(1, Math.floor(params['period'] ?? 14)); + return lengthParam(params, 'period', 14); } /** diff --git a/packages/chart/src/indicators/params.ts b/packages/chart/src/indicators/params.ts index 0504fdb..c508ae4 100644 --- a/packages/chart/src/indicators/params.ts +++ b/packages/chart/src/indicators/params.ts @@ -4,6 +4,8 @@ * @module */ +import type { IndicatorDef } from './types.js'; + /** A whole-number length: floored and at least `min`; `fallback` when missing or non-finite. */ export function lengthParam(params: Record, key: string, fallback: number, min = 1): number { const value = params[key]; @@ -15,3 +17,29 @@ export function numberParam(params: Record, key: string, fallbac const value = params[key]; return value !== undefined && Number.isFinite(value) ? value : fallback; } + +/** + * Params for one indicator instance: non-finite values fall back to + * `defaultParams`, then each declared input is clamped to `min`/`max` and + * rounded when `integer`, matching the indicators dialog. + */ +export function normalizeIndicatorParams( + def: Pick, + params: Record | undefined, +): Record { + const next: Record = { ...def.defaultParams, ...params }; + for (const input of def.inputs ?? []) { + const value = next[input.key]; + if (value === undefined || !Number.isFinite(value)) { + const fallback = def.defaultParams[input.key]; + if (fallback === undefined) delete next[input.key]; + else next[input.key] = fallback; + continue; + } + let clamped = input.integer === true ? Math.round(value) : value; + if (input.min !== undefined) clamped = Math.max(input.min, clamped); + if (input.max !== undefined) clamped = Math.min(input.max, clamped); + next[input.key] = clamped; + } + return next; +} diff --git a/packages/chart/src/indicators/rsi.ts b/packages/chart/src/indicators/rsi.ts index 45c2b69..91a2651 100644 --- a/packages/chart/src/indicators/rsi.ts +++ b/packages/chart/src/indicators/rsi.ts @@ -7,6 +7,7 @@ import type { Candle } from '../core/data.js'; import { TailState, TailSeries, smallTail, setSeriesValue, type MutableSeries } from './tail.js'; import type { IndicatorDef } from './types.js'; +import { lengthParam } from './params.js'; const averages = new WeakMap>(); @@ -112,10 +113,10 @@ export const rsiIndicator: IndicatorDef = { update(output, candles, from, params) { if (!smallTail(candles.length, from)) return undefined; return updateRsi(output.lines[0].values as (number | null)[], candles, from, - Math.max(1, Math.floor(params['period'] ?? 14))) ? output : undefined; + lengthParam(params, 'period', 14)) ? output : undefined; }, compute(candles: readonly Candle[], params: Record, colors: readonly string[]) { - const period = Math.max(1, Math.floor(params['period'] ?? 14)); + const period = lengthParam(params, 'period', 14); return { pane: 'sub', lines: [{ key: 'value', values: rsiCandleValues(candles, period), color: colors[0] ?? '#7e57c2' }], diff --git a/packages/chart/src/indicators/sma.ts b/packages/chart/src/indicators/sma.ts index cf49cd0..144bba5 100644 --- a/packages/chart/src/indicators/sma.ts +++ b/packages/chart/src/indicators/sma.ts @@ -8,6 +8,7 @@ import type { Candle } from '../core/data.js'; import type { IndicatorDef } from './types.js'; import { TailState, smallTail } from './tail.js'; import type { WasmKernels } from '../wasm/loader.js'; +import { lengthParam } from './params.js'; const wasmStates = new WeakMap>(); @@ -55,7 +56,7 @@ export function updateSma(out: (number | null)[], candles: readonly Candle[], fr for (let i = from; i < candles.length; i++) { sum += candles[i].close; if (i >= period) sum -= candles[i - period].close; - out[i] = i >= period - 1 ? sum / period : null; + out[i] = sum / period; state!.save(i, candles.length, sum); } state!.prune(candles.length); @@ -82,7 +83,7 @@ export const smaIndicator: IndicatorDef = { styles: [{ key: 'value', label: 'MA', colorIndex: 0 }], update(output, candles, from, params, _colors, kernels) { if (!smallTail(candles.length, from)) return undefined; - const period = Math.max(1, Math.floor(params['period'] ?? 20)); + const period = lengthParam(params, 'period', 20); if (kernels !== null) { const out = output.lines[0].values as (number | null)[]; const state = wasmStates.get(out); @@ -106,7 +107,7 @@ export const smaIndicator: IndicatorDef = { colors: readonly string[], kernels: WasmKernels | null, ) { - const period = Math.max(1, Math.floor(params['period'] ?? 20)); + const period = lengthParam(params, 'period', 20); let values: (number | null)[]; if (kernels !== null) { const closes = new Float32Array(candles.length); diff --git a/packages/chart/src/render/indicator-draw.ts b/packages/chart/src/render/indicator-draw.ts index c6b789f..427d480 100644 --- a/packages/chart/src/render/indicator-draw.ts +++ b/packages/chart/src/render/indicator-draw.ts @@ -58,6 +58,7 @@ export function indicatorMinMax(output: IndicatorOutput, from: number, to: numbe extendByLines(extent, output.lines, from, to); extendByLines(extent, output.fillLines ?? NO_LINES, from, to); for (const level of output.levels ?? []) { + if (!Number.isFinite(level.value)) continue; if (level.value < extent.min) extent.min = level.value; if (level.value > extent.max) extent.max = level.value; } @@ -65,7 +66,7 @@ export function indicatorMinMax(output: IndicatorOutput, from: number, to: numbe } function isValue(value: number | null | undefined): value is number { - return value != null && !Number.isNaN(value); + return value != null && Number.isFinite(value); } /** The line a fill boundary `key` names: a stroked line, else a fill-only one. */ diff --git a/packages/chart/src/wasm/loader.ts b/packages/chart/src/wasm/loader.ts index 3aab072..179102e 100644 --- a/packages/chart/src/wasm/loader.ts +++ b/packages/chart/src/wasm/loader.ts @@ -15,11 +15,15 @@ import { KERNELS_BASE64 } from './kernels.base64.js'; export interface WasmKernels { /** True when the SIMD kernels are in use. */ readonly usingSimd: boolean; - /** Min and max of a float array (NaN pair when empty). */ + /** + * Min and max of a float array. A NaN anywhere makes both results NaN + * (SIMD min/max propagate it). An empty array is also a NaN pair. + * {@link visibleMinMax} uses the scalar path when a window is not finite. + */ minmax(values: Float32Array): { min: number; max: number }; - /** Simple moving average; NaN before `period - 1`. */ + /** Simple moving average; NaN before `period - 1`, and for every index when `period` is below 1. */ sma(src: Float32Array, period: number): Float32Array; - /** Exponential moving average, SMA-seeded; NaN before `period - 1`. */ + /** Exponential moving average, SMA-seeded; NaN before `period - 1`, and for every index when `period` is below 1. */ ema(src: Float32Array, period: number): Float32Array; /** * Sliding-window maximum over `period` values (floored); NaN before @@ -143,17 +147,21 @@ function wrapExports(raw: RawKernelExports, usingSimd: boolean): WasmKernels { return { min, max }; }, sma(src: Float32Array, period: number): Float32Array { + const p = Math.floor(period); + if (!(p >= 1)) return new Float32Array(src.length).fill(NaN); const dstOffset = src.byteLength; ensureCapacity(dstOffset + src.byteLength); writeInput(src); - raw.sma_f32(0, dstOffset, src.length, period); + raw.sma_f32(0, dstOffset, src.length, p); return new Float32Array(raw.memory.buffer, dstOffset, src.length).slice(); }, ema(src: Float32Array, period: number): Float32Array { + const p = Math.floor(period); + if (!(p >= 1)) return new Float32Array(src.length).fill(NaN); const dstOffset = src.byteLength; ensureCapacity(dstOffset + src.byteLength); writeInput(src); - raw.ema_f32(0, dstOffset, src.length, period); + raw.ema_f32(0, dstOffset, src.length, p); return new Float32Array(raw.memory.buffer, dstOffset, src.length).slice(); }, rollingMax: (src, period) => rolling(raw.rolling_max_f32, src, period), diff --git a/packages/chart/test/indicator-compute-parity.test.ts b/packages/chart/test/indicator-compute-parity.test.ts index d8528cd..42bc4a7 100644 --- a/packages/chart/test/indicator-compute-parity.test.ts +++ b/packages/chart/test/indicator-compute-parity.test.ts @@ -1042,13 +1042,16 @@ it('full compute matches frozen pre-optimization implementations with Object.is for (const length of [0, 1, 6, 7, 8, 9, 13, 14, 19, 20, 25, 26, 51, 52, 1000]) for (const mode of [0, 1, 2, 3]) { const candles = walk(length, seed, mode); for (const params of variants) { + if (Object.values(params).some((value) => typeof value === 'number' && !Number.isFinite(value))) { + // Non-finite periods used to trap inside the WASM kernels. They now + // fall back to a finite length, so compute must not throw. + assert.doesNotThrow(() => current.compute(candles, params, [], kernels)); + continue; + } let expected; try { expected = reference.compute(candles, params, [], kernels); } catch (error) { - // Existing WASM kernels reject some non-finite periods with a trap. - assert.throws(() => current.compute(candles, params, [], kernels), (actual: unknown) => - actual instanceof Error && error instanceof Error && actual.constructor === error.constructor && actual.message === error.message); - continue; + assert.fail(`unexpected trap for ${reference.name}: ${error instanceof Error ? error.message : String(error)}`); } const actual = current.compute(candles, params, [], kernels); // strict deepEqual compares number leaves using Object.is and detects holes. diff --git a/packages/chart/test/indicator-invariants.test.ts b/packages/chart/test/indicator-invariants.test.ts new file mode 100644 index 0000000..0b7ab65 --- /dev/null +++ b/packages/chart/test/indicator-invariants.test.ts @@ -0,0 +1,125 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createChart, visibleMinMax, type Chart } from '../dist/index.js'; +import { MockDocument } from '../dist/dom.js'; +import { normalizeIndicatorParams } from '../dist/indicators/params.js'; +import { createIndicatorRegistry, type IndicatorDef } from '../dist/indicators/index.js'; +import { seriesMinMax } from '../dist/core/scale.js'; +import { initWasm } from '../dist/wasm/loader.js'; +import { smaCandleValues } from '../dist/indicators/sma.js'; +import type { Candle } from '../dist/core/data.js'; + +const data: Candle[] = Array.from({ length: 40 }, (_, i) => ({ + time: 1_700_000_000 + i * 60, + open: 100 + i, + high: 102 + i, + low: 99 + i, + close: 101 + i, + volume: 10, +})); + +function chart(): Chart { + return createChart({ document: new MockDocument(), config: { wasm: false, data } }); +} + +describe('indicator params', () => { + it('drops non-finite params back to defaults and clamps to the declared range (#97)', () => { + const normalized = normalizeIndicatorParams( + { defaultParams: { a: 1 }, inputs: [{ key: 'a', label: 'A' }, { key: 'b', label: 'B', integer: true, min: 1, max: 4 }] }, + { a: Number.POSITIVE_INFINITY, b: 2.6 }, + ); + assert.equal(normalized.a, 1); + assert.equal(normalized.b, 3); + const dropped = normalizeIndicatorParams( + { defaultParams: {}, inputs: [{ key: 'b', label: 'B' }] }, + { b: Number.NaN }, + ); + assert.equal('b' in dropped, false); + + const c = chart(); + const boll = c.addIndicator({ name: 'boll', params: { mult: Number.POSITIVE_INFINITY, period: 1.2 } }); + assert.equal(c.getIndicator(boll)?.params.mult, 2); + assert.equal(c.getIndicator(boll)?.params.period, 1); + c.updateIndicator(boll, { params: { mult: -2 } }); + assert.equal(c.getIndicator(boll)?.params.mult, 0.001); + const sma = c.addIndicator({ name: 'sma', params: { period: Number.NaN } }); + assert.equal(c.getIndicator(sma)?.params.period, 20); + assert.equal(smaCandleValues(data, Number.NaN).length, data.length); + const y = c.scale.priceToY(data[20]!.close); + assert.ok(y >= 0 && y <= c.plotArea.height); + c.destroy(); + + const configured = createChart({ + document: new MockDocument(), + config: { + wasm: false, + data, + indicators: [{ id: 'b', name: 'boll', params: { mult: Number.NEGATIVE_INFINITY, period: 20 }, pane: 'main', colors: [], visible: true }], + }, + }); + assert.equal(configured.getIndicator('b')?.params.mult, 2); + configured.updateConfig({ + indicators: [{ id: 'b', name: 'boll', params: { mult: 80, period: 20 }, pane: 'main', colors: [], visible: true }], + }); + assert.equal(configured.getIndicator('b')?.params.mult, 50); + configured.destroy(); + }); +}); + +describe('indicator output bounds', () => { + it('ignores non-finite indicator values when autoscaling (#98)', () => { + assert.deepEqual(seriesMinMax([1, Number.POSITIVE_INFINITY, null, Number.NaN, 3], 0, 5), { min: 1, max: 3 }); + const def: IndicatorDef = { + name: 'inf', + defaultParams: {}, + defaultColors: ['#fff'], + defaultPane: 'main', + compute: (candles) => ({ + pane: 'main', + lines: [{ key: 'v', color: '#fff', values: candles.map(() => Number.POSITIVE_INFINITY) }], + levels: [{ value: Number.POSITIVE_INFINITY, color: '#fff' }], + }), + }; + const c = createChart({ + document: new MockDocument(), + registries: { indicators: createIndicatorRegistry(false).register(def) }, + config: { wasm: false, data }, + }); + c.addIndicator({ name: 'inf' }); + const y = c.scale.priceToY(data[10]!.close); + assert.ok(y >= 0 && y <= c.plotArea.height); + c.destroy(); + }); +}); + +describe('wasm kernel guards', () => { + it('returns NaN instead of trapping when sma or ema period is below 1 (#99)', async () => { + const kernels = await initWasm(); + assert.ok(kernels); + assert.equal(kernels.sma(new Float32Array(0), 0).length, 0); + assert.equal(kernels.ema(new Float32Array(0), Number.NaN).length, 0); + const sma = kernels.sma(Float32Array.of(1, 2, 3, 4, 5, 6, 7, 8), 0); + const ema = kernels.ema(Float32Array.of(1, 2, 3, 4, 5, 6, 7, 8), Number.POSITIVE_INFINITY); + assert.ok([...sma].every(Number.isNaN)); + assert.ok([...ema].every(Number.isNaN)); + const c = createChart({ document: new MockDocument(), config: { wasm: true, data: [] } }); + await c.ready; + assert.doesNotThrow(() => c.addIndicator({ name: 'sma', params: { period: Number.NaN } })); + c.render(); + c.destroy(); + }); + + it('keeps visibleMinMax finite when a high is NaN and documents NaN minmax (#100)', async () => { + const kernels = await initWasm(); + assert.ok(kernels); + const mm = kernels.minmax(Float32Array.of(1, Number.NaN, 3, 4)); + assert.ok(Number.isNaN(mm.min) && Number.isNaN(mm.max)); + const candles = data.map((candle, i) => i === 3 ? { ...candle, high: Number.NaN } : candle); + const scalar = visibleMinMax(candles, 0, candles.length, null); + const simd = visibleMinMax(candles, 0, candles.length, kernels); + assert.deepEqual(simd, scalar); + assert.ok(Number.isFinite(simd.min) && Number.isFinite(simd.max)); + const lowNaN = data.map((candle, i) => i === 1 ? { ...candle, low: Number.NaN } : candle); + assert.deepEqual(visibleMinMax(lowNaN, 0, lowNaN.length, kernels), visibleMinMax(lowNaN, 0, lowNaN.length, null)); + }); +}); diff --git a/packages/chart/test/indicators-dialog.test.ts b/packages/chart/test/indicators-dialog.test.ts index 297dd20..f680e01 100644 --- a/packages/chart/test/indicators-dialog.test.ts +++ b/packages/chart/test/indicators-dialog.test.ts @@ -907,13 +907,20 @@ describe('indicators dialog: review hardening', () => { assert.equal(m.dialog.view, null); }); + it('shows a definition default when the stored params omit that key', () => { + const m = mount(); + m.chart.getConfig().indicators.push({ id: 'bare', name: 'sma', params: {}, pane: 'main', colors: [], visible: true, lineWidths: [], hiddenLines: [] }); + m.dialog.openSettings('bare'); + assert.equal(m.field('input-period').value, '20'); + }); + it('Cancel reverts params that the config left to the defaults', () => { const m = mount({ config: { indicators: [{ id: 'bare', name: 'macd', params: {}, pane: 'sub', colors: [], visible: true }] } }); const defaults = macdIndicator.compute(data, macdIndicator.defaultParams, macdIndicator.defaultColors, null).lines.map((l) => l.values); m.dialog.openSettings('bare'); m.change('input-fast', '5'); m.change('input-slow', '40'); - assert.deepEqual(m.chart.getIndicator('bare')!.params, { fast: 5, slow: 40 }); + assert.deepEqual(m.chart.getIndicator('bare')!.params, { fast: 5, slow: 40, signal: 9 }); assert.notDeepEqual(m.outputs.get('macd')!.lines.map((l) => l.values), defaults); m.press('Cancel'); assert.equal(m.dialog.view, null); diff --git a/packages/chart/test/indicators-studies.test.ts b/packages/chart/test/indicators-studies.test.ts index 1011de9..3b8e5aa 100644 --- a/packages/chart/test/indicators-studies.test.ts +++ b/packages/chart/test/indicators-studies.test.ts @@ -634,16 +634,16 @@ describe('ma-ribbon', () => { }); describe('degenerate study params', () => { - it('yield empty lines instead of throwing for NaN or infinite lengths', () => { + it('falls back to the default length for NaN or infinite lengths', () => { const candles = walk(40, 97); for (const period of [NaN, Infinity]) { for (const def of [cciIndicator, mfiIndicator]) { - const output = def.compute(candles, { period }, [], null); - assert.ok(output.lines[0]!.values.every((v) => v === null), `${def.name}(${period})`); - assert.equal(output.lines[0]!.values.length, 40); + assert.deepEqual(def.compute(candles, { period }, [], null), def.compute(candles, def.defaultParams, [], null), def.name); } - const adx = adxIndicator.compute(candles, { diLength: period, adxSmoothing: 3 }, [], null); - assert.ok(adx.lines.every((line) => line.values.every((v) => v === null)), `adx(${period})`); + assert.deepEqual( + adxIndicator.compute(candles, { diLength: period, adxSmoothing: 3 }, [], null), + adxIndicator.compute(candles, { diLength: 14, adxSmoothing: 3 }, [], null), + ); const ribbon = maRibbonIndicator.compute(candles, { len1: period, len2: 0, len3: 0, len4: 3 }, [], null); assert.deepEqual(ribbon.lines.map((l) => l.key), ['ma4']); assert.deepEqual(vwapIndicator.compute(candles, { anchor: period, bands: period }, [], null), vwapIndicator.compute(candles, {}, [], null));