Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions packages/chart/src/core/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
Expand Down
158 changes: 133 additions & 25 deletions packages/chart/src/core/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Candle>(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`.
*/
Expand Down Expand Up @@ -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. */
Expand All @@ -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;
}

/**
Expand Down
13 changes: 6 additions & 7 deletions packages/chart/src/core/prepend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, Candle>();
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);
}
Expand Down
38 changes: 25 additions & 13 deletions packages/chart/src/datafeed/candles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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<number, 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;
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);
}
Loading