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
15 changes: 14 additions & 1 deletion packages/chart/src/core/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -421,6 +422,7 @@ export class Chart {
this.ready = Promise.resolve(this);
}
this.assertConfigIds();
this.normalizeConfiguredIndicators();
this.render();
if (options.autoResize === true) this.startAutoResize();
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 12 additions & 2 deletions packages/chart/src/core/scale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand All @@ -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)[],
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/chart/src/indicators/adx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -94,7 +95,7 @@ function runAdx(
}

function lengths(params: Record<string, number>): [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)];
}

/**
Expand Down
5 changes: 3 additions & 2 deletions packages/chart/src/indicators/boll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -96,7 +97,7 @@ export const bollIndicator: IndicatorDef = {
return output;
},
compute(candles: readonly Candle[], params: Record<string, number>, 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);
Expand Down
3 changes: 2 additions & 1 deletion packages/chart/src/indicators/cci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -43,7 +44,7 @@ function fillCci(candles: readonly Candle[], period: number, values: (number | n
}

function periodParam(params: Record<string, number>): number {
return Math.max(1, Math.floor(params['period'] ?? 20));
return lengthParam(params, 'period', 20);
}

/**
Expand Down
5 changes: 3 additions & 2 deletions packages/chart/src/indicators/ema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly (number | null)[], TailState<number>>();

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions packages/chart/src/indicators/kdj.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)[]);
Expand All @@ -92,7 +93,7 @@ export const kdjIndicator: IndicatorDef = {
return output;
},
compute(candles: readonly Candle[], params: Record<string, number>, 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',
Expand Down
15 changes: 8 additions & 7 deletions packages/chart/src/indicators/macd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IndicatorOutput, TailState<number[]>>();

Expand All @@ -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)[]);
Expand All @@ -59,9 +60,9 @@ export const macdIndicator: IndicatorDef = {
return output;
},
compute(candles: readonly Candle[], params: Record<string, number>, 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);
Expand Down
9 changes: 3 additions & 6 deletions packages/chart/src/indicators/mfi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,17 @@ 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
* rose from the previous candle and negative when it fell; like TradingView,
* 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);
Expand All @@ -45,7 +42,7 @@ function fillMfi(candles: readonly Candle[], period: number, values: (number | n
}

function periodParam(params: Record<string, number>): number {
return Math.max(1, Math.floor(params['period'] ?? 14));
return lengthParam(params, 'period', 14);
}

/**
Expand Down
28 changes: 28 additions & 0 deletions packages/chart/src/indicators/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>, key: string, fallback: number, min = 1): number {
const value = params[key];
Expand All @@ -15,3 +17,29 @@ export function numberParam(params: Record<string, number>, 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<IndicatorDef, 'defaultParams' | 'inputs'>,
params: Record<string, number> | undefined,
): Record<string, number> {
const next: Record<string, number> = { ...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;
}
5 changes: 3 additions & 2 deletions packages/chart/src/indicators/rsi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MutableSeries, TailState<number[]>>();

Expand Down Expand Up @@ -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<string, number>, 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' }],
Expand Down
7 changes: 4 additions & 3 deletions packages/chart/src/indicators/sma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly (number | null)[], TailState<number>>();

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion packages/chart/src/render/indicator-draw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,15 @@ 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;
}
return extent.min <= extent.max ? extent : null;
}

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. */
Expand Down
Loading