Files
trade-frontend/apps/visualizer/src/features/chart/TradingChart.tsx

768 lines
27 KiB
TypeScript

import { useEffect, useMemo, useRef } from 'react';
import {
CandlestickSeries,
ColorType,
CrosshairMode,
HistogramSeries,
type IChartApi,
type ISeriesApi,
LineStyle,
LineSeries,
createChart,
type UTCTimestamp,
type CandlestickData,
type HistogramData,
type LineData,
type WhitespaceData,
} from 'lightweight-charts';
import type { Candle, SeriesPoint } from '../../lib/api';
import { FibRetracementPrimitive, type FibAnchor, type FibRetracement } from './FibRetracementPrimitive';
type Props = {
candles: Candle[];
oracle?: SeriesPoint[];
sma20?: SeriesPoint[];
ema20?: SeriesPoint[];
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
showIndicators: boolean;
bucketSeconds: number;
seriesKey: string;
fib?: FibRetracement | null;
fibOpacity?: number;
fibSelected?: boolean;
priceAutoScale?: boolean;
onReady?: (api: { chart: IChartApi; candles: ISeriesApi<'Candlestick', UTCTimestamp> }) => void;
onChartClick?: (p: FibAnchor & { target: 'chart' | 'fib' }) => void;
onChartCrosshairMove?: (p: FibAnchor) => void;
onPointerEvent?: (p: {
type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel';
logical: number;
price: number;
x: number;
y: number;
target: 'chart' | 'fib';
event: PointerEvent;
}) => { consume?: boolean; capturePointer?: boolean } | void;
};
type LinePoint = LineData | WhitespaceData;
type BuildSample = { t: number; v: number };
function toTime(t: number): UTCTimestamp {
return t as UTCTimestamp;
}
function resolveBucketSeconds(bucketSeconds: number, candles: Candle[]): number {
if (Number.isFinite(bucketSeconds) && bucketSeconds > 0) return bucketSeconds;
if (candles.length >= 2) {
const last = candles[candles.length - 1]?.time;
const prev = candles[candles.length - 2]?.time;
const delta = typeof last === 'number' && typeof prev === 'number' ? last - prev : 0;
if (Number.isFinite(delta) && delta > 0) return delta;
}
return 60;
}
function samplePriceFromCandles(candles: Candle[]): number | null {
for (let i = candles.length - 1; i >= 0; i -= 1) {
const close = candles[i]?.close;
if (typeof close !== 'number') continue;
if (!Number.isFinite(close)) continue;
if (close === 0) continue;
return close;
}
return null;
}
function priceFormatForSample(price: number) {
const abs = Math.abs(price);
if (!Number.isFinite(abs) || abs === 0) return { type: 'price' as const, precision: 2, minMove: 0.01 };
if (abs >= 1000) return { type: 'price' as const, precision: 0, minMove: 1 };
if (abs >= 1) return { type: 'price' as const, precision: 2, minMove: 0.01 };
const exponent = Math.floor(Math.log10(abs)); // negative for abs < 1
const precision = Math.min(8, Math.max(4, -exponent + 3));
return { type: 'price' as const, precision, minMove: Math.pow(10, -precision) };
}
function toCandleData(candles: Candle[]): CandlestickData[] {
return candles.map((c) => ({
time: toTime(c.time),
open: c.open,
high: c.high,
low: c.low,
close: c.close,
}));
}
function toVolumeData(candles: Candle[]): HistogramData[] {
return candles.map((c) => {
return {
time: toTime(c.time),
value: c.volume ?? 0,
color: 'rgba(148,163,184,0.22)',
};
});
}
function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
if (!points?.length) return [];
return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }));
}
export default function TradingChart({
candles,
oracle,
sma20,
ema20,
bb20,
showIndicators,
bucketSeconds,
seriesKey,
fib,
fibOpacity = 1,
fibSelected = false,
priceAutoScale = true,
onReady,
onChartClick,
onChartCrosshairMove,
onPointerEvent,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const fibPrimitiveRef = useRef<FibRetracementPrimitive | null>(null);
const fibRef = useRef<FibRetracement | null>(fib ?? null);
const fibOpacityRef = useRef<number>(fibOpacity);
const priceAutoScaleRef = useRef<boolean>(priceAutoScale);
const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale);
const onReadyRef = useRef<Props['onReady']>(onReady);
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
const capturedOverlayPointerRef = useRef<number | null>(null);
const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map());
const buildKeyRef = useRef<string | null>(null);
const lastBuildCandleStartRef = useRef<number | null>(null);
const seriesRef = useRef<{
candles?: ISeriesApi<'Candlestick'>;
volume?: ISeriesApi<'Histogram'>;
build?: ISeriesApi<'Line'>;
oracle?: ISeriesApi<'Line'>;
sma20?: ISeriesApi<'Line'>;
ema20?: ISeriesApi<'Line'>;
bbUpper?: ISeriesApi<'Line'>;
bbLower?: ISeriesApi<'Line'>;
bbMid?: ISeriesApi<'Line'>;
}>({});
const candleData = useMemo(() => toCandleData(candles), [candles]);
const volumeData = useMemo(() => toVolumeData(candles), [candles]);
const oracleData = useMemo(() => toLineSeries(oracle), [oracle]);
const smaData = useMemo(() => toLineSeries(sma20), [sma20]);
const emaData = useMemo(() => toLineSeries(ema20), [ema20]);
const bbUpper = useMemo(() => toLineSeries(bb20?.upper), [bb20?.upper]);
const bbLower = useMemo(() => toLineSeries(bb20?.lower), [bb20?.lower]);
const bbMid = useMemo(() => toLineSeries(bb20?.mid), [bb20?.mid]);
const priceFormat = useMemo(() => {
const sample = samplePriceFromCandles(candles);
return sample == null ? { type: 'price' as const, precision: 2, minMove: 0.01 } : priceFormatForSample(sample);
}, [candles]);
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
useEffect(() => {
onChartClickRef.current = onChartClick;
}, [onChartClick]);
useEffect(() => {
onChartCrosshairMoveRef.current = onChartCrosshairMove;
}, [onChartCrosshairMove]);
useEffect(() => {
onPointerEventRef.current = onPointerEvent;
}, [onPointerEvent]);
useEffect(() => {
fibRef.current = fib ?? null;
}, [fib]);
useEffect(() => {
fibOpacityRef.current = fibOpacity;
fibPrimitiveRef.current?.setOpacity(fibOpacity);
}, [fibOpacity]);
useEffect(() => {
priceAutoScaleRef.current = priceAutoScale;
}, [priceAutoScale]);
useEffect(() => {
if (!containerRef.current) return;
if (chartRef.current) return;
const chart = createChart(containerRef.current, {
layout: {
background: { type: ColorType.Solid, color: 'rgba(0,0,0,0)' },
textColor: '#e6e9ef',
fontFamily:
'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif',
},
grid: {
vertLines: { color: 'rgba(255,255,255,0.06)' },
horzLines: { color: 'rgba(255,255,255,0.06)' },
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: 'rgba(255,255,255,0.18)', style: LineStyle.Dashed },
horzLine: { color: 'rgba(255,255,255,0.18)', style: LineStyle.Dashed },
},
rightPriceScale: { borderColor: 'rgba(255,255,255,0.08)' },
timeScale: { borderColor: 'rgba(255,255,255,0.08)', timeVisible: true, secondsVisible: false },
handleScale: { mouseWheel: true, pinch: true },
handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true },
});
chartRef.current = chart;
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#22c55e',
downColor: '#ef4444',
borderVisible: false,
wickUpColor: '#22c55e',
wickDownColor: '#ef4444',
priceFormat,
});
const fibPrimitive = new FibRetracementPrimitive();
candleSeries.attachPrimitive(fibPrimitive);
fibPrimitiveRef.current = fibPrimitive;
fibPrimitive.setFib(fib ?? null);
fibPrimitive.setOpacity(fibOpacityRef.current);
const volumeSeries = chart.addSeries(HistogramSeries, {
priceFormat: { type: 'volume' },
priceScaleId: '',
color: 'rgba(255,255,255,0.15)',
});
volumeSeries.priceScale().applyOptions({
scaleMargins: { top: 0.88, bottom: 0 },
});
const buildSeries = chart.addSeries(LineSeries, {
color: '#60a5fa',
lineWidth: 2,
priceFormat,
priceScaleId: 'build',
lastValueVisible: false,
priceLineVisible: false,
crosshairMarkerVisible: false,
});
buildSeries.priceScale().applyOptions({
scaleMargins: { top: 0.72, bottom: 0.12 },
visible: false,
borderVisible: false,
});
const oracleSeries = chart.addSeries(LineSeries, {
color: 'rgba(251,146,60,0.9)',
lineWidth: 1,
lineStyle: LineStyle.Dotted,
priceFormat,
});
const smaSeries = chart.addSeries(LineSeries, { color: 'rgba(248,113,113,0.9)', lineWidth: 1, priceFormat });
const emaSeries = chart.addSeries(LineSeries, { color: 'rgba(52,211,153,0.9)', lineWidth: 1, priceFormat });
const bbUpperSeries = chart.addSeries(LineSeries, { color: 'rgba(250,204,21,0.6)', lineWidth: 1, priceFormat });
const bbLowerSeries = chart.addSeries(LineSeries, { color: 'rgba(163,163,163,0.6)', lineWidth: 1, priceFormat });
const bbMidSeries = chart.addSeries(LineSeries, {
color: 'rgba(250,204,21,0.35)',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
priceFormat,
});
seriesRef.current = {
candles: candleSeries,
volume: volumeSeries,
build: buildSeries,
oracle: oracleSeries,
sma20: smaSeries,
ema20: emaSeries,
bbUpper: bbUpperSeries,
bbLower: bbLowerSeries,
bbMid: bbMidSeries,
};
onReadyRef.current?.({ chart, candles: candleSeries as ISeriesApi<'Candlestick', UTCTimestamp> });
const onClick = (param: any) => {
if (!param?.point) return;
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
if (logical == null) return;
const price = candleSeries.coordinateToPrice(param.point.y);
if (price == null) return;
const currentFib = fibRef.current;
let target: 'chart' | 'fib' = 'chart';
if (currentFib) {
const x1 = chart.timeScale().logicalToCoordinate(currentFib.a.logical as any);
const x2 = chart.timeScale().logicalToCoordinate(currentFib.b.logical as any);
if (x1 != null && x2 != null) {
const tol = 6;
const left = Math.min(x1, x2) - tol;
const right = Math.max(x1, x2) + tol;
const p0 = currentFib.a.price;
const delta = currentFib.b.price - p0;
const yRatioMin = 0;
const yRatioMax = 4.236;
const pMin = Math.min(p0 + delta * yRatioMin, p0 + delta * yRatioMax);
const pMax = Math.max(p0 + delta * yRatioMin, p0 + delta * yRatioMax);
const y1 = candleSeries.priceToCoordinate(pMin);
const y2 = candleSeries.priceToCoordinate(pMax);
if (y1 != null && y2 != null) {
const top = Math.min(y1, y2) - tol;
const bottom = Math.max(y1, y2) + tol;
if (param.point.x >= left && param.point.x <= right && param.point.y >= top && param.point.y <= bottom) {
target = 'fib';
}
}
}
}
onChartClickRef.current?.({ logical: Number(logical), price: Number(price), target });
};
chart.subscribeClick(onClick);
const onCrosshairMove = (param: any) => {
if (!param?.point) return;
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
if (logical == null) return;
const price = candleSeries.coordinateToPrice(param.point.y);
if (price == null) return;
onChartCrosshairMoveRef.current?.({ logical: Number(logical), price: Number(price) });
};
chart.subscribeCrosshairMove(onCrosshairMove);
const container = containerRef.current;
const toAnchorFromPoint = (x: number, y: number): FibAnchor | null => {
const logical = chart.timeScale().coordinateToLogical(x);
if (logical == null) return null;
const price = candleSeries.coordinateToPrice(y);
if (price == null) return null;
return { logical: Number(logical), price: Number(price) };
};
const isOverFib = (x: number, y: number, currentFib: FibRetracement): boolean => {
const x1 = chart.timeScale().logicalToCoordinate(currentFib.a.logical as any);
const x2 = chart.timeScale().logicalToCoordinate(currentFib.b.logical as any);
if (x1 == null || x2 == null) return false;
const tol = 6;
const left = Math.min(x1, x2) - tol;
const right = Math.max(x1, x2) + tol;
const p0 = currentFib.a.price;
const delta = currentFib.b.price - p0;
const yRatioMin = 0;
const yRatioMax = 4.236;
const pMin = Math.min(p0 + delta * yRatioMin, p0 + delta * yRatioMax);
const pMax = Math.max(p0 + delta * yRatioMin, p0 + delta * yRatioMax);
const y1 = candleSeries.priceToCoordinate(pMin);
const y2 = candleSeries.priceToCoordinate(pMax);
if (y1 == null || y2 == null) return false;
const top = Math.min(y1, y2) - tol;
const bottom = Math.max(y1, y2) + tol;
return x >= left && x <= right && y >= top && y <= bottom;
};
const onOverlayPointer = (e: PointerEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const anchor = toAnchorFromPoint(x, y);
if (!anchor) return;
const currentFib = fibRef.current;
const target: 'chart' | 'fib' = currentFib && isOverFib(x, y, currentFib) ? 'fib' : 'chart';
const type =
e.type === 'pointerdown'
? ('pointerdown' as const)
: e.type === 'pointermove'
? ('pointermove' as const)
: e.type === 'pointerup'
? ('pointerup' as const)
: ('pointercancel' as const);
const decision = onPointerEventRef.current?.({
type,
logical: anchor.logical,
price: anchor.price,
x,
y,
target,
event: e,
});
if (decision?.capturePointer) {
capturedOverlayPointerRef.current = e.pointerId;
try {
containerRef.current.setPointerCapture(e.pointerId);
} catch {
// ignore
}
}
if (decision?.consume) {
e.preventDefault();
e.stopImmediatePropagation();
}
if (type === 'pointerup' || type === 'pointercancel') {
if (capturedOverlayPointerRef.current === e.pointerId) {
capturedOverlayPointerRef.current = null;
try {
containerRef.current.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
}
}
};
container?.addEventListener('pointerdown', onOverlayPointer, { capture: true });
container?.addEventListener('pointermove', onOverlayPointer, { capture: true });
container?.addEventListener('pointerup', onOverlayPointer, { capture: true });
container?.addEventListener('pointercancel', onOverlayPointer, { capture: true });
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return;
if (!containerRef.current) return;
e.preventDefault();
e.stopPropagation();
if (priceAutoScaleRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const y = e.clientY - rect.top;
const pivotPrice = candleSeries.coordinateToPrice(y);
if (pivotPrice == null) return;
const ps = candleSeries.priceScale();
const range = ps.getVisibleRange();
let low: number;
let high: number;
if (range) {
low = Math.min(range.from, range.to);
high = Math.max(range.from, range.to);
} else {
const top = candleSeries.coordinateToPrice(0);
const bottom = candleSeries.coordinateToPrice(rect.height);
if (top == null || bottom == null) return;
low = Math.min(top, bottom);
high = Math.max(top, bottom);
}
const span = high - low;
if (!(span > 0)) return;
const zoomFactor = Math.exp(e.deltaY * 0.002);
if (!Number.isFinite(zoomFactor) || zoomFactor <= 0) return;
const t = Math.min(1, Math.max(0, (pivotPrice - low) / span));
const minSpan = span * 1e-6;
const maxSpan = span * 1e6;
const nextSpan = Math.min(maxSpan, Math.max(minSpan, span * zoomFactor));
const nextLow = pivotPrice - t * nextSpan;
const nextHigh = nextLow + nextSpan;
ps.setAutoScale(false);
ps.setVisibleRange({ from: nextLow, to: nextHigh });
};
container?.addEventListener('wheel', onWheel, { passive: false, capture: true });
let pan: { pointerId: number; startPrice: number; startRange: { from: number; to: number } } | null = null;
const onPointerDown = (e: PointerEvent) => {
if (e.button !== 0) return;
if (!e.ctrlKey) return;
if (priceAutoScaleRef.current) return;
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const y = e.clientY - rect.top;
const startPrice = candleSeries.coordinateToPrice(y);
if (startPrice == null) return;
const ps = candleSeries.priceScale();
const range = ps.getVisibleRange();
let from: number;
let to: number;
if (range) {
from = range.from;
to = range.to;
} else {
const top = candleSeries.coordinateToPrice(0);
const bottom = candleSeries.coordinateToPrice(rect.height);
if (top == null || bottom == null) return;
from = Math.min(top, bottom);
to = Math.max(top, bottom);
}
if (!(to > from)) return;
pan = { pointerId: e.pointerId, startPrice, startRange: { from, to } };
try {
containerRef.current.setPointerCapture(e.pointerId);
} catch {
// ignore
}
e.preventDefault();
e.stopPropagation();
};
const onPointerMove = (e: PointerEvent) => {
if (!pan) return;
if (pan.pointerId !== e.pointerId) return;
if (!containerRef.current) return;
if (!e.ctrlKey || priceAutoScaleRef.current) {
pan = null;
return;
}
const rect = containerRef.current.getBoundingClientRect();
const y = e.clientY - rect.top;
const currentPrice = candleSeries.coordinateToPrice(y);
if (currentPrice == null) return;
const delta = pan.startPrice - currentPrice;
const ps = candleSeries.priceScale();
ps.setAutoScale(false);
ps.setVisibleRange({ from: pan.startRange.from + delta, to: pan.startRange.to + delta });
e.preventDefault();
e.stopPropagation();
};
const stopPan = (e: PointerEvent) => {
if (!pan) return;
if (pan.pointerId !== e.pointerId) return;
pan = null;
try {
containerRef.current?.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
};
container?.addEventListener('pointerdown', onPointerDown, { capture: true });
container?.addEventListener('pointermove', onPointerMove, { capture: true });
container?.addEventListener('pointerup', stopPan, { capture: true });
container?.addEventListener('pointercancel', stopPan, { capture: true });
const ro = new ResizeObserver(() => {
if (!containerRef.current) return;
const { width, height } = containerRef.current.getBoundingClientRect();
chart.applyOptions({ width: Math.floor(width), height: Math.floor(height) });
});
ro.observe(containerRef.current);
return () => {
chart.unsubscribeClick(onClick);
chart.unsubscribeCrosshairMove(onCrosshairMove);
container?.removeEventListener('pointerdown', onOverlayPointer, { capture: true });
container?.removeEventListener('pointermove', onOverlayPointer, { capture: true });
container?.removeEventListener('pointerup', onOverlayPointer, { capture: true });
container?.removeEventListener('pointercancel', onOverlayPointer, { capture: true });
container?.removeEventListener('wheel', onWheel, { capture: true });
container?.removeEventListener('pointerdown', onPointerDown, { capture: true });
container?.removeEventListener('pointermove', onPointerMove, { capture: true });
container?.removeEventListener('pointerup', stopPan, { capture: true });
container?.removeEventListener('pointercancel', stopPan, { capture: true });
ro.disconnect();
if (fibPrimitiveRef.current) {
candleSeries.detachPrimitive(fibPrimitiveRef.current);
fibPrimitiveRef.current = null;
}
chart.remove();
chartRef.current = null;
seriesRef.current = {};
};
}, []);
useEffect(() => {
const s = seriesRef.current;
if (!s.candles || !s.volume || !s.build) return;
s.candles.setData(candleData);
s.volume.setData(volumeData);
s.oracle?.setData(oracleData);
s.sma20?.setData(smaData);
s.ema20?.setData(emaData);
s.bbUpper?.setData(bbUpper);
s.bbLower?.setData(bbLower);
s.bbMid?.setData(bbMid);
if (buildKeyRef.current !== seriesKey) {
buildSamplesRef.current.clear();
buildKeyRef.current = seriesKey;
lastBuildCandleStartRef.current = null;
}
const bs = resolveBucketSeconds(bucketSeconds, candles);
const eps = 1e-3;
const maxPointsPerCandle = 600;
const minStep = Math.max(0.5, bs / maxPointsPerCandle);
const map = buildSamplesRef.current;
const visibleStarts = new Set(candles.map((c) => c.time));
for (const start of Array.from(map.keys())) {
if (!visibleStarts.has(start)) map.delete(start);
}
const last = candles[candles.length - 1];
if (last) {
const prevStart = lastBuildCandleStartRef.current;
if (prevStart != null && prevStart !== last.time) {
const prevCandle = candles.find((c) => c.time === prevStart);
if (prevCandle) {
const endT = prevStart + bs - eps;
const finalDelta = prevCandle.close - prevCandle.open;
const list = map.get(prevStart) ?? [];
const lastT = list.length ? list[list.length - 1]!.t : -Infinity;
if (endT > lastT + eps) {
list.push({ t: endT, v: finalDelta });
} else if (list.length) {
list[list.length - 1] = { t: lastT, v: finalDelta };
}
map.set(prevStart, list);
}
}
const start = last.time;
const endT = start + bs - eps;
const delta = last.close - last.open;
const nowT = Date.now() / 1000;
const tClamped = Math.min(endT, Math.max(start + eps, nowT));
const list = map.get(start) ?? [];
if (list.length) {
const lastPt = list[list.length - 1]!;
if (tClamped - lastPt.t < minStep) {
list[list.length - 1] = { t: tClamped, v: delta };
} else {
const t = Math.min(endT, Math.max(lastPt.t + eps, tClamped));
if (t > lastPt.t) list.push({ t, v: delta });
}
} else {
list.push({ t: tClamped, v: delta });
}
map.set(start, list);
lastBuildCandleStartRef.current = start;
}
const buildRaw: LinePoint[] = [];
for (const c of candles) {
const list = map.get(c.time);
if (!list?.length) continue;
const startT = c.time + eps;
const endT = c.time + bs - eps;
if (!(endT > startT)) continue;
buildRaw.push({ time: toTime(c.time) } as WhitespaceData);
buildRaw.push({ time: toTime(startT), value: 0 } as LineData);
let lastT = startT;
for (const p of list) {
let t = p.t;
if (t <= lastT + eps) t = lastT + eps;
if (t >= endT) break;
buildRaw.push({ time: toTime(t), value: p.v } as LineData);
lastT = t;
}
const finalDelta = c.close - c.open;
if (endT > lastT + eps) {
buildRaw.push({ time: toTime(endT), value: finalDelta } as LineData);
} else if (buildRaw.length) {
const prev = buildRaw[buildRaw.length - 1];
if ('value' in prev) {
buildRaw[buildRaw.length - 1] = { ...prev, value: finalDelta } as LineData;
}
}
}
const buildColored: LinePoint[] = [];
let lastLineIdx: number | null = null;
let lastLineVal: number | null = null;
for (const p of buildRaw) {
if (!('value' in p)) {
buildColored.push(p);
lastLineIdx = null;
lastLineVal = null;
continue;
}
if (lastLineIdx != null && lastLineVal != null) {
const delta = p.value - lastLineVal;
const color = delta > 0 ? '#22c55e' : delta < 0 ? '#ef4444' : '#60a5fa';
const prev = buildColored[lastLineIdx] as LineData;
buildColored[lastLineIdx] = { ...prev, color };
}
buildColored.push({ time: p.time, value: p.value } as LineData);
lastLineIdx = buildColored.length - 1;
lastLineVal = p.value;
}
s.build.setData(buildColored);
s.sma20?.applyOptions({ visible: showIndicators });
s.ema20?.applyOptions({ visible: showIndicators });
s.bbUpper?.applyOptions({ visible: showIndicators });
s.bbLower?.applyOptions({ visible: showIndicators });
s.bbMid?.applyOptions({ visible: showIndicators });
}, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators, candles, bucketSeconds, seriesKey]);
useEffect(() => {
const s = seriesRef.current;
if (!s.candles) return;
s.candles.applyOptions({ priceFormat });
s.build?.applyOptions({ priceFormat });
s.oracle?.applyOptions({ priceFormat });
s.sma20?.applyOptions({ priceFormat });
s.ema20?.applyOptions({ priceFormat });
s.bbUpper?.applyOptions({ priceFormat });
s.bbLower?.applyOptions({ priceFormat });
s.bbMid?.applyOptions({ priceFormat });
}, [priceFormat]);
useEffect(() => {
fibPrimitiveRef.current?.setFib(fib ?? null);
}, [fib]);
useEffect(() => {
fibPrimitiveRef.current?.setSelected(Boolean(fibSelected));
}, [fibSelected]);
useEffect(() => {
const candlesSeries = seriesRef.current.candles;
if (!candlesSeries) return;
const ps = candlesSeries.priceScale();
const prev = prevPriceAutoScaleRef.current;
prevPriceAutoScaleRef.current = priceAutoScale;
if (priceAutoScale) {
ps.setAutoScale(true);
return;
}
ps.setAutoScale(false);
if (!prev) return;
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const top = candlesSeries.coordinateToPrice(0);
const bottom = candlesSeries.coordinateToPrice(rect.height);
if (top == null || bottom == null) return;
const from = Math.min(top, bottom);
const to = Math.max(top, bottom);
if (!(to > from)) return;
ps.setVisibleRange({ from, to });
}, [priceAutoScale]);
return <div className="tradingChart" ref={containerRef} />;
}