diff --git a/packages/chart/src/core/chart.ts b/packages/chart/src/core/chart.ts index 5c09118..785b63e 100644 --- a/packages/chart/src/core/chart.ts +++ b/packages/chart/src/core/chart.ts @@ -20,7 +20,7 @@ import { scaleFont } from '../render/scale-font.js'; import { attachInlineBook, type InlineBookApi } from '../render/inline-book.js'; import { attachHeatmap, type HeatmapApi } from '../render/heatmap.js'; import { parseColor } from '../color.js'; -import { DataStore, type Candle } from './data.js'; +import { DataStore, finiteCandle, type Candle } from './data.js'; import type { DepthBook } from './depth.js'; import { PriceScale, TimeScale, seriesMinMax, visibleMinMax, type VisibleRange } from './scale.js'; import type { SlotRange } from './scale.js'; @@ -460,21 +460,23 @@ export class Chart { } /** - * Appends or replaces one candle (by time) and re-renders. A candle older - * than the first one goes in front like {@link prependData} (reported as - * `'prepend'`, with index-based drawings moving along); one between - * existing bars is inserted in place and reported as `'append'`. + * Appends or replaces one candle (by time) and re-renders. A non-finite time + * or OHLC price is ignored. `high`/`low` expand to cover `open` and `close`, + * and a negative or non-finite volume becomes 0. A candle older than the + * first one goes in front like {@link prependData} (reported as `'prepend'`, + * with index-based drawings moving along); one between existing bars is + * inserted in place and reported as `'append'`. */ appendData(candle: Candle): void { const length = this.store.length; - if (length > 0 && candle.time < this.store.at(0)!.time) { + if (length > 0 && finiteCandle(candle) && candle.time < this.store.at(0)!.time) { this.insertHistory([candle]); return; } const last = this.store.last(); const tail = last === undefined || candle.time > last.time || (candle.time === last.time && this.store.at(length - 2)?.time !== candle.time); - this.store.append(candle); + if (!this.store.append(candle)) return; this.timeSlots.sync(this.store.raw(), this.config.timeScale, tail ? 'append' : 'data'); this.events.dataLoaded(this.store.length > length ? 'append' : 'update', this.store.length - length); if (tail) { @@ -488,7 +490,8 @@ export class Chart { /** * Inserts older history in front of the data and re-renders. Rows at or * after the first candle's time, or with a non-finite time or price, are - * ignored; the rest are deduped by time (last wins) and sorted. The viewport + * ignored; the rest are deduped by time (last wins), sorted, and repaired + * (`high`/`low` cover the body; a negative volume becomes 0). The viewport * stays on the same candles at the same pixels, index-based drawing points * (and the draft) move with their bars, and indicators recompute. Returns * the number of candles added. diff --git a/packages/chart/src/core/data.ts b/packages/chart/src/core/data.ts index f5eba11..61acd35 100644 --- a/packages/chart/src/core/data.ts +++ b/packages/chart/src/core/data.ts @@ -14,6 +14,105 @@ export interface Candle { readonly volume?: number; } +/** + * Seconds above this are year 5138 or later, but the same number of + * milliseconds is only 1973. Real second-based data never reaches it. + */ +const MILLISECOND_TIME = 1e11; + +let warnedMillisecondTime = false; + +/** Clears the one-shot millisecond warning. Intended for tests. */ +export function resetCandleTimeWarning(): void { + warnedMillisecondTime = false; +} + +function nodeEnv(): string | undefined { + const proc = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process; + if (proc === undefined) return undefined; + if (proc.env === undefined) return undefined; + return proc.env.NODE_ENV; +} + +/** + * Warns once, outside production, when `time` is too large to be UNIX seconds. + * The candle is kept as given: callers pass seconds, and this does not rescale. + */ +export function noteCandleTime(time: number): void { + if (!(time > MILLISECOND_TIME) || warnedMillisecondTime) return; + if (nodeEnv() === 'production') return; + warnedMillisecondTime = true; + (globalThis as unknown as { console: { warn: (message: string) => void } }).console.warn( + `chart-ts: candle time ${time} looks like milliseconds; Candle.time is UNIX seconds`, + ); +} + +/** Whether `time`, `open`, `high`, `low` and `close` are finite numbers. */ +export function finiteCandle(candle: Candle): boolean { + return Number.isFinite(candle.time) + && Number.isFinite(candle.open) + && Number.isFinite(candle.high) + && Number.isFinite(candle.low) + && Number.isFinite(candle.close); +} + +/** + * Expands `high`/`low` so they cover `open` and `close`, and replaces a + * negative or non-finite volume with 0. Returns `candle` itself when nothing + * changes. Assumes the OHLC fields are already finite. + */ +export function repairCandle(candle: Candle): Candle { + if (candle.time > MILLISECOND_TIME) noteCandleTime(candle.time); + let high = candle.high; + let low = candle.low; + if (candle.open > high) high = candle.open; + if (candle.close > high) high = candle.close; + if (candle.open < low) low = candle.open; + if (candle.close < low) low = candle.close; + const volume = candle.volume; + const badVolume = volume !== undefined && (!(volume >= 0) || !Number.isFinite(volume)); + if (high === candle.high && low === candle.low && !badVolume) return candle; + if (volume === undefined) return { time: candle.time, open: candle.open, high, low, close: candle.close }; + return { time: candle.time, open: candle.open, high, low, close: candle.close, volume: badVolume ? 0 : volume }; +} + +/** Index of the first row that is non-finite, out of order, duplicated, or inconsistent; `length` when every row is usable. */ +function firstDirty(candles: readonly Candle[]): number { + let prev = -Infinity; + for (let i = 0; i < candles.length; i++) { + const candle = candles[i]!; + const time = candle.time; + if (!(time > prev) || !finiteCandle(candle)) return i; + if (candle.low > candle.open || candle.low > candle.close || candle.open > candle.high || candle.close > candle.high) return i; + const volume = candle.volume; + if (volume !== undefined && (!(volume >= 0) || !Number.isFinite(volume))) return i; + if (time > MILLISECOND_TIME) noteCandleTime(time); + prev = time; + } + return candles.length; +} + +/** Drops non-finite rows, repairs OHLC and volume, sorts ascending, and keeps the last row for each time. */ +function sanitizeCandles(candles: readonly Candle[]): Candle[] { + const out = new Array(candles.length); + let count = 0; + for (let i = 0; i < candles.length; i++) { + const candle = candles[i]!; + if (!finiteCandle(candle)) continue; + out[count++] = repairCandle(candle); + } + out.length = count; + out.sort((a, b) => a.time - b.time); + let write = 0; + for (let read = 0; read < out.length; read++) { + const row = out[read]!; + if (write > 0 && out[write - 1]!.time === row.time) out[write - 1] = row; + else out[write++] = row; + } + out.length = write; + return out; +} + /** * Ordered, array-backed candle storage keyed by ascending `time`. */ @@ -49,25 +148,30 @@ export class DataStore { } /** - * Replaces the whole dataset. Input must be sorted by ascending time; - * it is defensively sorted otherwise. + * Replaces the whole dataset. Non-finite rows are dropped, duplicate times + * keep the last row, `high`/`low` expand to cover `open` and `close`, and a + * negative or non-finite volume becomes 0. Already-clean input keeps the + * same candle objects. */ setData(candles: readonly Candle[]): void { - this.candles = candles.slice(); - for (let i = 1; i < this.candles.length; i++) { - if (this.candles[i - 1].time > this.candles[i].time) { - this.candles.sort((a, b) => a.time - b.time); - break; - } - } + this.candles = firstDirty(candles) === candles.length ? candles.slice() : sanitizeCandles(candles); } /** - * Inserts `older` in front of the dataset. The caller guarantees it is - * sorted, unique and strictly earlier than the first candle. + * Inserts `older` in front of the dataset. Rows are cleaned like + * {@link setData}. Anything that is not strictly earlier than the first + * stored candle is dropped, so the existing series wins ties. */ prepend(older: readonly Candle[]): void { - this.candles = older.concat(this.candles); + const rows = firstDirty(older) === older.length ? older : sanitizeCandles(older); + const firstTime = this.candles[0]?.time; + if (firstTime === undefined) { + this.candles = rows.concat(this.candles); + return; + } + let count = rows.length; + while (count > 0 && rows[count - 1]!.time >= firstTime) count--; + this.candles = (count === rows.length ? rows : rows.slice(0, count)).concat(this.candles); } /** Removes all candles. */ @@ -76,27 +180,31 @@ export class DataStore { } /** - * Appends one candle. When its time already exists the candle is replaced; - * out-of-order times are inserted at the correct position via binary search. + * Appends one candle, repaired like {@link setData}. A non-finite time or + * OHLC price is ignored and returns false. When its time already exists the + * candle is replaced; out-of-order times are inserted via binary search. */ - append(candle: Candle): void { + append(candle: Candle): boolean { + if (!finiteCandle(candle)) return false; + const next = repairCandle(candle); const last = this.last(); - if (last === undefined || candle.time > last.time) { - this.candles.push(candle); - return; + if (last === undefined || next.time > last.time) { + this.candles.push(next); + return true; } // Keep lowerBound's first-match behavior for duplicate timestamps. - if (candle.time === last.time && this.candles[this.candles.length - 2]?.time !== candle.time) { - this.candles[this.candles.length - 1] = candle; - return; + if (next.time === last.time && this.candles[this.candles.length - 2]?.time !== next.time) { + this.candles[this.candles.length - 1] = next; + return true; } - const idx = this.lowerBound(candle.time); + const idx = this.lowerBound(next.time); const existing = this.candles[idx]; - if (existing !== undefined && existing.time === candle.time) { - this.candles[idx] = candle; + if (existing !== undefined && existing.time === next.time) { + this.candles[idx] = next; } else { - this.candles.splice(idx, 0, candle); + this.candles.splice(idx, 0, next); } + return true; } /** diff --git a/packages/chart/src/core/prepend.ts b/packages/chart/src/core/prepend.ts index 74a8b10..f4c6edd 100644 --- a/packages/chart/src/core/prepend.ts +++ b/packages/chart/src/core/prepend.ts @@ -7,22 +7,21 @@ */ import type { DrawingPoint } from '../config.js'; -import type { Candle } from './data.js'; - -/** Whether every field a candle is drawn and scaled from is a finite number. */ -const drawable = (c: Candle): boolean => - Number.isFinite(c.time) && Number.isFinite(c.open) && Number.isFinite(c.high) && Number.isFinite(c.low) && Number.isFinite(c.close); +import { finiteCandle, repairCandle, type Candle } from './data.js'; /** * Rows of `candles` strictly older than `firstTime` (all of them when it is * undefined), deduped by `time` with the last row winning and sorted * ascending. Rows with a non-finite time, open, high, low or close are dropped - * (before deduping, so they never displace a valid row). + * (before deduping, so they never displace a valid row). Kept rows have + * `high`/`low` expanded to cover `open` and `close`, and a negative or + * non-finite volume replaced with 0. */ export function olderCandles(candles: readonly Candle[], firstTime: number | undefined): Candle[] { const byTime = new Map(); for (const candle of candles) { - if (drawable(candle) && (firstTime === undefined || candle.time < firstTime)) byTime.set(candle.time, candle); + if (!finiteCandle(candle) || (firstTime !== undefined && candle.time >= firstTime)) continue; + byTime.set(candle.time, repairCandle(candle)); } return [...byTime.values()].sort((a, b) => a.time - b.time); } diff --git a/packages/chart/src/datafeed/candles.ts b/packages/chart/src/datafeed/candles.ts index 5fde01e..0c33cc1 100644 --- a/packages/chart/src/datafeed/candles.ts +++ b/packages/chart/src/datafeed/candles.ts @@ -7,7 +7,7 @@ * @module */ -import type { Candle } from '../core/data.js'; +import { noteCandleTime, type Candle } from '../core/data.js'; /** Open time (ms) of the `intervalMs` bucket containing `nowMs`; buckets are epoch-aligned. */ export function bucketStartMs(nowMs: number, intervalMs: number): number { @@ -39,10 +39,28 @@ export function applyTick(bar: Candle, price: number, bucketMs: number): Candle return { ...bar, high: Math.max(bar.high, price), low: Math.min(bar.low, price), close: price }; } +/** A fresh OHLCV row, or null when the time or OHLC cannot be drawn. */ +function normalizedRow(candle: Candle): Candle | null { + const { time, open, high, low, close } = candle; + if (!Number.isFinite(open) || !Number.isFinite(high) || !Number.isFinite(low) || !Number.isFinite(close)) return null; + if (!Number.isFinite(time) || !(time > 0)) return null; + if (time > 1e11) noteCandleTime(time); + const volume = candle.volume ?? 0; + return { + time, + open, + high: Math.max(high, open, close), + low: Math.min(low, open, close), + close, + volume: volume >= 0 && Number.isFinite(volume) ? volume : 0, + }; +} + /** * Cleans a history page: drops rows with a non-finite OHLC or a non-finite / - * non-positive time, zeroes a missing or non-finite volume, dedupes on `time` - * with the last row winning, and sorts ascending. Returns fresh candles. + * non-positive time, expands `high`/`low` to cover `open` and `close`, zeroes + * a missing, non-finite or negative volume, dedupes on `time` with the last + * row winning, and sorts ascending. Returns fresh candles. */ export function normalizeCandles(candles: readonly Candle[]): Candle[] { let increasing = true; @@ -57,21 +75,15 @@ export function normalizeCandles(candles: readonly Candle[]): Candle[] { if (increasing) { const rows: Candle[] = []; for (const candle of candles) { - const { time, open, high, low, close } = candle; - if (!Number.isFinite(open) || !Number.isFinite(high) || !Number.isFinite(low) || !Number.isFinite(close)) continue; - if (!Number.isFinite(time) || time <= 0) continue; - const volume = candle.volume ?? 0; - rows.push({ time, open, high, low, close, volume: Number.isFinite(volume) ? volume : 0 }); + const row = normalizedRow(candle); + if (row !== null) rows.push(row); } return rows; } const byTime = new Map(); for (const candle of candles) { - const { time, open, high, low, close } = candle; - if (!Number.isFinite(open) || !Number.isFinite(high) || !Number.isFinite(low) || !Number.isFinite(close)) continue; - if (!Number.isFinite(time) || time <= 0) continue; - const volume = candle.volume ?? 0; - byTime.set(time, { time, open, high, low, close, volume: Number.isFinite(volume) ? volume : 0 }); + const row = normalizedRow(candle); + if (row !== null) byTime.set(row.time, row); } return [...byTime.values()].sort((a, b) => a.time - b.time); } diff --git a/packages/chart/test/candle-invariants.test.ts b/packages/chart/test/candle-invariants.test.ts new file mode 100644 index 0000000..11df25a --- /dev/null +++ b/packages/chart/test/candle-invariants.test.ts @@ -0,0 +1,252 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createChart, type Chart } from '../dist/index.js'; +import { DataStore, finiteCandle, noteCandleTime, repairCandle, resetCandleTimeWarning, type Candle } from '../dist/core/data.js'; +import { olderCandles } from '../dist/core/prepend.js'; +import { normalizeCandles } from '../dist/datafeed/index.js'; +import { MockDocument } from '../dist/dom.js'; +import { mfiValues } from '../dist/indicators/index.js'; +import { smaCandleValues } from '../dist/indicators/sma.js'; + +const T0 = 1_700_000_000; + +function candle(time: number, close = 10, volume?: number): Candle { + const row: Candle = { time, open: close, high: close + 1, low: close - 1, close }; + if (volume !== undefined) return { ...row, volume }; + return row; +} + +function chartWith(data: readonly Candle[]): Chart { + return createChart({ document: new MockDocument(), config: { wasm: false, data: data.slice() } }); +} + +function warns(fn: () => void): unknown[][] { + const seen: unknown[][] = []; + const orig = console.warn; + console.warn = (...args: unknown[]) => { seen.push(args); }; + try { + fn(); + } finally { + console.warn = orig; + } + return seen; +} + +describe('candle ingestion invariants', () => { + it('drops non-finite OHLC on setData, appendData, config.data and updateConfig (#88)', () => { + const good = candle(T0, 10); + const fields = ['time', 'open', 'high', 'low', 'close'] as const; + const bad: Candle[] = []; + for (const field of fields) { + bad.push({ ...good, time: T0 + bad.length + 1, [field]: Number.NaN }); + bad.push({ ...good, time: T0 + bad.length + 1, [field]: Number.POSITIVE_INFINITY }); + bad.push({ ...good, time: T0 + bad.length + 1, [field]: Number.NEGATIVE_INFINITY }); + } + const store = new DataStore(); + store.setData([candle(300), candle(Number.NaN), candle(100), candle(200), ...bad]); + assert.deepEqual(store.all().map((row) => row.time), [100, 200, 300]); + + const chart = chartWith([good]); + const seen: string[] = []; + chart.subscribeDataLoad((event) => seen.push(event.reason)); + for (const row of bad) chart.appendData(row); + chart.appendData({ time: T0 - 60, open: 1, high: 2, low: 0, close: Number.NaN }); + assert.equal(chart.dataLength, 1); + assert.deepEqual(chart.getData().map((row) => row.time), [good.time]); + assert.deepEqual(seen, []); + assert.equal(store.append(bad[0]!), false); + const empty = chartWith([]); + empty.appendData({ time: Number.NaN, open: 1, high: 1, low: 1, close: 1 }); + assert.equal(empty.dataLength, 0); + empty.appendData(candle(T0, 3)); + assert.equal(empty.getData()[0]?.close, 3); + empty.destroy(); + + const constructed = chartWith([bad[1]!, good, bad[2]!]); + assert.deepEqual(constructed.getData(), [good]); + constructed.updateConfig({ data: [candle(T0 + 60, Number.NaN), candle(T0 + 120, 12)] }); + assert.deepEqual(constructed.getData().map((row) => row.close), [12]); + + const poisoned = [candle(T0, 10), candle(T0 + 60, 11), { ...candle(T0 + 120, 12), close: Number.NaN }, candle(T0 + 180, 13)]; + const cleaned = chartWith(poisoned); + const sma = smaCandleValues(cleaned.getData(), 3); + assert.ok(sma.every((value) => value === null || Number.isFinite(value))); + assert.equal(cleaned.dataLength, 3); + chart.destroy(); + constructed.destroy(); + cleaned.destroy(); + }); + + it('keeps one candle per time, and a later append replaces that bar (#89)', () => { + const rows = [candle(T0, 1), candle(T0 + 60, 2), candle(T0 + 120, 3)]; + const duplicate = { ...rows[2]!, close: 999, high: 1000 }; + const chart = chartWith([...rows, duplicate]); + assert.equal(chart.dataLength, 3); + assert.equal(chart.getData()[2]!.close, 999); + chart.appendData({ ...rows[2]!, close: 5 }); + assert.equal(chart.dataLength, 3); + assert.equal(chart.getData()[2]!.close, 5); + assert.deepEqual(chart.getData().map((row) => row.time), [T0, T0 + 60, T0 + 120]); + + const store = new DataStore(); + store.setData([candle(T0 + 120, 3), candle(T0, 1), { ...candle(T0, 8), close: 4 }, candle(T0 + 60, 2)]); + assert.deepEqual(store.all().map((row) => [row.time, row.close]), [[T0, 4], [T0 + 60, 2], [T0 + 120, 3]]); + chart.destroy(); + }); + + it('repairs OHLC at ingestion so autoscale includes the body (#90)', () => { + const rows = Array.from({ length: 50 }, (_, i) => candle(T0 + i * 60, 100 + i)); + rows[49] = { ...rows[49]!, close: 400 }; + const inputHigh = rows[49]!.high; + const chart = chartWith(rows); + const last = chart.getData()[49]!; + assert.equal(rows[49]!.high, inputHigh, 'input candles are not mutated'); + assert.equal(last.high, 400); + assert.ok(last.low <= Math.min(last.open, last.close)); + const y = chart.scale.priceToY(last.close); + assert.ok(y >= 0 && y <= chart.plotArea.height, `close y ${y}`); + + const swapped = { time: T0, open: 10, high: 4, low: 12, close: 8 }; + chart.setData([swapped]); + assert.deepEqual(chart.getData()[0], { time: T0, open: 10, high: 10, low: 8, close: 8 }); + chart.appendData({ time: T0 + 60, open: 3, high: 4, low: 5, close: 1 }); + assert.deepEqual(chart.getData()[1], { time: T0 + 60, open: 3, high: 4, low: 1, close: 1 }); + chart.prependData([{ time: T0 - 60, open: 7, high: 6, low: 9, close: 8, volume: 1 }]); + assert.equal(chart.getData()[0]!.high, 8); + assert.equal(chart.getData()[0]!.low, 7); + chart.destroy(); + + const store = new DataStore(); + const bare = { time: T0, open: 5, high: 4, low: 3, close: 6 }; + store.append(bare); + assert.equal(bare.high, 4); + assert.deepEqual(store.at(0), { time: T0, open: 5, high: 6, low: 3, close: 6 }); + const openAbove = { time: T0 + 60, open: 9, high: 8, low: 7, close: 7.5 }; + store.append(openAbove); + assert.equal(store.at(1)?.high, 9); + assert.equal(store.at(1)?.volume, undefined); + const closeBelow = { time: T0 + 120, open: 5, high: 10, low: 4, close: 2, volume: 3 }; + store.append(closeBelow); + assert.equal(store.at(2)?.low, 2); + assert.equal(store.at(2)?.volume, 3); + store.setData([candle(T0, 1), { time: T0 + 60, open: 10, high: 9, low: 8, close: 8.5 }]); + assert.equal(store.at(1)?.high, 10); + store.setData([candle(T0, 1), { time: T0 + 60, open: 10, high: 12, low: 8, close: 7 }]); + assert.equal(store.at(1)?.low, 7); + store.setData([candle(T0, 1, 4), candle(T0 + 60, 2, Number.POSITIVE_INFINITY)]); + assert.deepEqual(store.all().map((row) => row.volume), [4, 0]); + }); + + it('clamps negative and non-finite volume to 0 and leaves MFI inside 0..100 (#91)', () => { + const rows = Array.from({ length: 30 }, (_, i) => candle(T0 + i * 60, 100 + (i % 5), i % 3 === 0 ? -50 : 20)); + rows[1] = { ...rows[1]!, volume: Number.NaN }; + rows[2] = { ...rows[2]!, volume: Number.POSITIVE_INFINITY }; + const chart = chartWith(rows); + assert.ok(chart.getData().every((row) => row.volume !== undefined && row.volume >= 0 && Number.isFinite(row.volume))); + const mfi = mfiValues(chart.getData(), 14); + assert.ok(mfi.every((value) => value === null || (value >= 0 && value <= 100))); + chart.appendData(candle(T0 + 30 * 60, 110, -8)); + assert.equal(chart.getData().at(-1)?.volume, 0); + chart.updateConfig({ data: [candle(T0, 10, -1), candle(T0 + 60, 11)] }); + assert.deepEqual(chart.getData().map((row) => row.volume), [0, undefined]); + chart.destroy(); + + const older = olderCandles([candle(T0 - 60, 9, -4), candle(T0 - 120, 8)], undefined); + assert.equal(older[1]!.volume, 0); + assert.equal(older[0]!.volume, undefined); + }); + + it('warns once in development when time looks like milliseconds and does not rescale (#92)', () => { + resetCandleTimeWarning(); + const ms = 1_700_000_000_000; + const boundary = warns(() => { + const store = new DataStore(); + store.setData([candle(1e11 - 60), candle(1e11)]); + assert.deepEqual(store.all().map((row) => row.time), [1e11 - 60, 1e11]); + }); + assert.deepEqual(boundary, []); + + const savedEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + const quiet = warns(() => { + assert.deepEqual(normalizeCandles([candle(ms, 10, 1)]).map((row) => row.time), [ms]); + }); + assert.deepEqual(quiet, []); + } finally { + if (savedEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = savedEnv; + } + + const runtime = globalThis as { process: NodeJS.Process | { env?: { NODE_ENV?: string } } | undefined }; + const savedProcess = runtime.process; + const missing = warns(() => { + runtime.process = undefined; + try { + noteCandleTime(ms); + } finally { + runtime.process = savedProcess; + } + }); + assert.equal(missing.length, 1); + resetCandleTimeWarning(); + + const noEnv = warns(() => { + runtime.process = {}; + try { + noteCandleTime(ms + 60_000); + } finally { + runtime.process = savedProcess; + } + }); + assert.equal(noEnv.length, 1); + assert.match(String(noEnv[0]?.[0]), /UNIX seconds/); + resetCandleTimeWarning(); + + const chart = chartWith([candle(T0)]); + const first = warns(() => { + chart.setData([candle(ms), candle(ms + 60_000)]); + chart.appendData(candle(ms + 120_000)); + chart.prependData([candle(ms - 60_000)]); + }); + assert.equal(first.length, 1); + assert.deepEqual(chart.getData().map((row) => row.time), [ms - 60_000, ms, ms + 60_000, ms + 120_000]); + chart.destroy(); + + const again = warns(() => noteCandleTime(ms + 1)); + assert.deepEqual(again, []); + const finite = repairCandle(candle(T0)); + assert.equal(finite, repairCandle(finite)); + assert.equal(finiteCandle(candle(T0)), true); + assert.equal(finiteCandle({ ...candle(T0), close: Number.NaN }), false); + }); + + it('dedupes DataStore.prepend against the candles already stored', () => { + const store = new DataStore(); + const kept = candle(T0, 1); + store.setData([kept, candle(T0 + 60, 2)]); + const earlier = candle(T0 - 120, 6); + store.prepend([earlier]); + assert.equal(store.at(0), earlier); + assert.equal(store.at(1), kept); + store.prepend([]); + assert.equal(store.at(1), kept); + store.prepend([candle(T0 - 180, 4), candle(T0, 9), candle(T0 + 120, 8)]); + assert.deepEqual(store.all().map((row) => row.time), [T0 - 180, T0 - 120, T0, T0 + 60]); + assert.equal(store.at(2), kept); + const length = store.length; + store.prepend([candle(T0 + 900)]); + assert.equal(store.length, length); + store.prepend([ + candle(T0 + 500), + { time: Number.NaN, open: 1, high: 2, low: 0, close: 1 }, + candle(T0 - 240, 3, -5), + ]); + assert.equal(store.at(0)?.time, T0 - 240); + assert.equal(store.at(0)?.volume, 0); + assert.equal(store.at(1)?.time, T0 - 180); + const empty = new DataStore(); + empty.prepend([candle(20), candle(10), candle(10, 4)]); + assert.deepEqual(empty.all().map((row) => [row.time, row.close]), [[10, 4], [20, 10]]); + }); +}); diff --git a/packages/chart/test/datafeed-candles.test.ts b/packages/chart/test/datafeed-candles.test.ts index 803b5cb..b8cdad7 100644 --- a/packages/chart/test/datafeed-candles.test.ts +++ b/packages/chart/test/datafeed-candles.test.ts @@ -120,9 +120,28 @@ describe('normalizeCandles', () => { assert.deepEqual(out, [good]); }); - it('zeroes a missing or non-finite volume', () => { - const out = normalizeCandles([row(1, 1), row(2, 2, Number.NaN), row(3, 3, Number.POSITIVE_INFINITY), row(4, 4, 0), row(5, 5, 7)]); - assert.deepEqual(out.map((c) => c.volume), [0, 0, 0, 0, 7]); + it('zeroes a missing, non-finite or negative volume', () => { + const out = normalizeCandles([row(1, 1), row(2, 2, Number.NaN), row(3, 3, Number.POSITIVE_INFINITY), row(4, 4, 0), row(5, 5, 7), row(6, 6, -3)]); + assert.deepEqual(out.map((c) => c.volume), [0, 0, 0, 0, 7, 0]); + }); + + it('drops invalid rows that are already in time order', () => { + const row = (time: number, close: number, volume?: number): Candle => + volume === undefined + ? { time, open: 100, high: 110, low: 95, close } + : { time, open: 100, high: 110, low: 95, close, volume }; + const out = normalizeCandles([ + { ...row(10, 1, 1), open: Number.NaN }, + row(20, 2, 2), + { ...row(30, 3, 3), close: Number.NaN }, + row(40, 4, -5), + ]); + assert.deepEqual(out.map((candle) => [candle.time, candle.volume]), [[20, 2], [40, 0]]); + }); + + it('expands high and low to cover open and close', () => { + const out = normalizeCandles([{ time: 1_700_000_000, open: 10, high: 8, low: 9, close: 12, volume: 1 }]); + assert.deepEqual(out, [{ time: 1_700_000_000, open: 10, high: 12, low: 9, close: 12, volume: 1 }]); }); it('returns an empty array for empty input', () => { @@ -138,7 +157,14 @@ function slowNormalize(candles: readonly Candle[]): Candle[] { if (!Number.isFinite(open) || !Number.isFinite(high) || !Number.isFinite(low) || !Number.isFinite(close)) continue; if (!Number.isFinite(time) || time <= 0) continue; const volume = candle.volume ?? 0; - byTime.set(time, { time, open, high, low, close, volume: Number.isFinite(volume) ? volume : 0 }); + byTime.set(time, { + time, + open, + high: Math.max(high, open, close), + low: Math.min(low, open, close), + close, + volume: volume >= 0 && Number.isFinite(volume) ? volume : 0, + }); } return [...byTime.values()].sort((a, b) => a.time - b.time); } @@ -153,6 +179,8 @@ describe('normalization path equivalence', () => { { ...row(4), close: NaN }, { time: 5, open: 1, high: 3, low: 0, close: 2 }, { ...row(6), volume: NaN }, + { ...row(8), volume: -4 }, + { time: 9, open: 5, high: 4, low: 6, close: 7, volume: 1 }, row(7), row(Infinity), ]; for (const [name, input] of [ diff --git a/packages/chart/test/performance.test.ts b/packages/chart/test/performance.test.ts index 20b0d43..48596ea 100644 --- a/packages/chart/test/performance.test.ts +++ b/packages/chart/test/performance.test.ts @@ -445,17 +445,20 @@ describe('linear rolling indicator accuracy', () => { }); describe('bounded data and histogram work', () => { - it('preserves duplicate-time first replacement and isolates sorted input arrays', () => { + it('keeps the last duplicate time and isolates sorted input arrays', () => { const store = new DataStore(); - const input = [data(1)[0]!, data(1)[0]!]; + const input = [data(1)[0]!, { ...data(1)[0]!, close: 7 }]; store.setData(input); + assert.equal(store.length, 1); + assert.equal(store.at(0)!.close, 7); + assert.equal(input[0]!.close, 100.5); + assert.equal(input[1]!.close, 7); store.append({ ...input[0]!, close: 42 }); + assert.equal(store.length, 1); assert.equal(store.at(0)!.close, 42); - assert.equal(store.at(1)!.close, 100.5); - assert.equal(input[0]!.close, 100.5); store.append({ ...input[0]!, time: 1 }); store.append({ ...input[0]!, time: 1, close: 43 }); - assert.equal(store.length, 3); + assert.equal(store.length, 2); assert.equal(store.last()!.close, 43); }); diff --git a/packages/chart/test/prepend-data.test.ts b/packages/chart/test/prepend-data.test.ts index 635ad8c..cc980ce 100644 --- a/packages/chart/test/prepend-data.test.ts +++ b/packages/chart/test/prepend-data.test.ts @@ -23,7 +23,7 @@ const close = (a: number, b: number, eps = 1e-9) => assert.ok(Math.abs(a - b) < /** Hourly bar `i` (negative for older history). */ function bar(i: number): Candle { const base = 100 + Math.sin(i / 7) * 10 + i / 50; - return { time: T0 + i * HOUR, open: base, high: base + 2 + (i % 3), low: base - 2, close: base + 1, volume: 1000 + i }; + return { time: T0 + i * HOUR, open: base, high: base + 2 + ((i % 3) + 3) % 3, low: base - 2, close: base + 1, volume: 1000 + i }; } const bars = (from: number, to: number): Candle[] => Array.from({ length: to - from }, (_, k) => bar(from + k)); @@ -73,8 +73,9 @@ describe('DataStore.prepend and prepend helpers', () => { it('olderCandles keeps strictly older rows, deduped (last wins) and sorted', () => { const late = { ...bar(-2), close: 1 }; + const repaired = { ...late, low: Math.min(late.low, late.open, late.close), high: Math.max(late.high, late.open, late.close) }; const rows = [bar(-1), bar(0), bar(1), bar(-2), late, { ...bar(-3), time: NaN }, bar(-4)]; - assert.deepEqual(olderCandles(rows, bar(0).time), [bar(-4), late, bar(-1)]); + assert.deepEqual(olderCandles(rows, bar(0).time), [bar(-4), repaired, bar(-1)]); assert.deepEqual(olderCandles([bar(1), bar(0)], undefined), [bar(0), bar(1)]); assert.deepEqual(olderCandles([], 5), []); });