chore: initial import

This commit is contained in:
u1
2026-01-06 12:33:47 +01:00
commit ed37565e25
38 changed files with 5707 additions and 0 deletions

View File

@@ -0,0 +1,259 @@
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;
fib?: FibRetracement | null;
onReady?: (api: { chart: IChartApi; candles: ISeriesApi<'Candlestick', UTCTimestamp> }) => void;
onChartClick?: (p: FibAnchor) => void;
onChartCrosshairMove?: (p: FibAnchor) => void;
};
type LinePoint = LineData | WhitespaceData;
function toTime(t: number): UTCTimestamp {
return t as UTCTimestamp;
}
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) => {
const up = c.close >= c.open;
return {
time: toTime(c.time),
value: c.volume ?? 0,
color: up ? 'rgba(34,197,94,0.35)' : 'rgba(239,68,68,0.35)',
};
});
}
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,
fib,
onReady,
onChartClick,
onChartCrosshairMove,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const fibPrimitiveRef = useRef<FibRetracementPrimitive | null>(null);
const onReadyRef = useRef<Props['onReady']>(onReady);
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
const seriesRef = useRef<{
candles?: ISeriesApi<'Candlestick'>;
volume?: ISeriesApi<'Histogram'>;
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]);
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
useEffect(() => {
onChartClickRef.current = onChartClick;
}, [onChartClick]);
useEffect(() => {
onChartCrosshairMoveRef.current = onChartCrosshairMove;
}, [onChartCrosshairMove]);
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',
});
const fibPrimitive = new FibRetracementPrimitive();
candleSeries.attachPrimitive(fibPrimitive);
fibPrimitiveRef.current = fibPrimitive;
fibPrimitive.setFib(fib ?? null);
const volumeSeries = chart.addSeries(HistogramSeries, {
priceFormat: { type: 'volume' },
priceScaleId: '',
color: 'rgba(255,255,255,0.15)',
});
volumeSeries.priceScale().applyOptions({
scaleMargins: { top: 0.82, bottom: 0 },
});
const oracleSeries = chart.addSeries(LineSeries, {
color: 'rgba(251,146,60,0.9)',
lineWidth: 1,
lineStyle: LineStyle.Dotted,
});
const smaSeries = chart.addSeries(LineSeries, { color: 'rgba(248,113,113,0.9)', lineWidth: 1 });
const emaSeries = chart.addSeries(LineSeries, { color: 'rgba(52,211,153,0.9)', lineWidth: 1 });
const bbUpperSeries = chart.addSeries(LineSeries, { color: 'rgba(250,204,21,0.6)', lineWidth: 1 });
const bbLowerSeries = chart.addSeries(LineSeries, { color: 'rgba(163,163,163,0.6)', lineWidth: 1 });
const bbMidSeries = chart.addSeries(LineSeries, {
color: 'rgba(250,204,21,0.35)',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
});
seriesRef.current = {
candles: candleSeries,
volume: volumeSeries,
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;
onChartClickRef.current?.({ logical: Number(logical), price: Number(price) });
};
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 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);
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) 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);
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]);
useEffect(() => {
fibPrimitiveRef.current?.setFib(fib ?? null);
}, [fib]);
return <div className="tradingChart" ref={containerRef} />;
}