feat(chart): add candle build indicator line

This commit is contained in:
u1
2026-01-07 08:09:09 +00:00
parent abaee44835
commit 1c8a6900e8

View File

@@ -25,6 +25,8 @@ type Props = {
ema20?: SeriesPoint[]; ema20?: SeriesPoint[];
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] }; bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
showIndicators: boolean; showIndicators: boolean;
bucketSeconds: number;
seriesKey: string;
fib?: FibRetracement | null; fib?: FibRetracement | null;
fibOpacity?: number; fibOpacity?: number;
fibSelected?: boolean; fibSelected?: boolean;
@@ -44,11 +46,23 @@ type Props = {
}; };
type LinePoint = LineData | WhitespaceData; type LinePoint = LineData | WhitespaceData;
type BuildSample = { t: number; v: number };
function toTime(t: number): UTCTimestamp { function toTime(t: number): UTCTimestamp {
return t as 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 { function samplePriceFromCandles(candles: Candle[]): number | null {
for (let i = candles.length - 1; i >= 0; i -= 1) { for (let i = candles.length - 1; i >= 0; i -= 1) {
const close = candles[i]?.close; const close = candles[i]?.close;
@@ -82,11 +96,10 @@ function toCandleData(candles: Candle[]): CandlestickData[] {
function toVolumeData(candles: Candle[]): HistogramData[] { function toVolumeData(candles: Candle[]): HistogramData[] {
return candles.map((c) => { return candles.map((c) => {
const up = c.close >= c.open;
return { return {
time: toTime(c.time), time: toTime(c.time),
value: c.volume ?? 0, value: c.volume ?? 0,
color: up ? 'rgba(34,197,94,0.35)' : 'rgba(239,68,68,0.35)', color: 'rgba(148,163,184,0.22)',
}; };
}); });
} }
@@ -103,6 +116,8 @@ export default function TradingChart({
ema20, ema20,
bb20, bb20,
showIndicators, showIndicators,
bucketSeconds,
seriesKey,
fib, fib,
fibOpacity = 1, fibOpacity = 1,
fibSelected = false, fibSelected = false,
@@ -124,9 +139,13 @@ export default function TradingChart({
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove); const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent); const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
const capturedOverlayPointerRef = useRef<number | null>(null); 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<{ const seriesRef = useRef<{
candles?: ISeriesApi<'Candlestick'>; candles?: ISeriesApi<'Candlestick'>;
volume?: ISeriesApi<'Histogram'>; volume?: ISeriesApi<'Histogram'>;
build?: ISeriesApi<'Line'>;
oracle?: ISeriesApi<'Line'>; oracle?: ISeriesApi<'Line'>;
sma20?: ISeriesApi<'Line'>; sma20?: ISeriesApi<'Line'>;
ema20?: ISeriesApi<'Line'>; ema20?: ISeriesApi<'Line'>;
@@ -225,7 +244,22 @@ export default function TradingChart({
color: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.15)',
}); });
volumeSeries.priceScale().applyOptions({ volumeSeries.priceScale().applyOptions({
scaleMargins: { top: 0.82, bottom: 0 }, 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, { const oracleSeries = chart.addSeries(LineSeries, {
@@ -249,6 +283,7 @@ export default function TradingChart({
seriesRef.current = { seriesRef.current = {
candles: candleSeries, candles: candleSeries,
volume: volumeSeries, volume: volumeSeries,
build: buildSeries,
oracle: oracleSeries, oracle: oracleSeries,
sma20: smaSeries, sma20: smaSeries,
ema20: emaSeries, ema20: emaSeries,
@@ -551,7 +586,7 @@ export default function TradingChart({
useEffect(() => { useEffect(() => {
const s = seriesRef.current; const s = seriesRef.current;
if (!s.candles || !s.volume) return; if (!s.candles || !s.volume || !s.build) return;
s.candles.setData(candleData); s.candles.setData(candleData);
s.volume.setData(volumeData); s.volume.setData(volumeData);
s.oracle?.setData(oracleData); s.oracle?.setData(oracleData);
@@ -561,17 +596,131 @@ export default function TradingChart({
s.bbLower?.setData(bbLower); s.bbLower?.setData(bbLower);
s.bbMid?.setData(bbMid); 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.sma20?.applyOptions({ visible: showIndicators });
s.ema20?.applyOptions({ visible: showIndicators }); s.ema20?.applyOptions({ visible: showIndicators });
s.bbUpper?.applyOptions({ visible: showIndicators }); s.bbUpper?.applyOptions({ visible: showIndicators });
s.bbLower?.applyOptions({ visible: showIndicators }); s.bbLower?.applyOptions({ visible: showIndicators });
s.bbMid?.applyOptions({ visible: showIndicators }); s.bbMid?.applyOptions({ visible: showIndicators });
}, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators]); }, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators, candles, bucketSeconds, seriesKey]);
useEffect(() => { useEffect(() => {
const s = seriesRef.current; const s = seriesRef.current;
if (!s.candles) return; if (!s.candles) return;
s.candles.applyOptions({ priceFormat }); s.candles.applyOptions({ priceFormat });
s.build?.applyOptions({ priceFormat });
s.oracle?.applyOptions({ priceFormat }); s.oracle?.applyOptions({ priceFormat });
s.sma20?.applyOptions({ priceFormat }); s.sma20?.applyOptions({ priceFormat });
s.ema20?.applyOptions({ priceFormat }); s.ema20?.applyOptions({ priceFormat });