diff --git a/apps/visualizer/src/features/chart/TradingChart.tsx b/apps/visualizer/src/features/chart/TradingChart.tsx index d3c3abc..44044b2 100644 --- a/apps/visualizer/src/features/chart/TradingChart.tsx +++ b/apps/visualizer/src/features/chart/TradingChart.tsx @@ -25,6 +25,8 @@ type Props = { ema20?: SeriesPoint[]; bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] }; showIndicators: boolean; + bucketSeconds: number; + seriesKey: string; fib?: FibRetracement | null; fibOpacity?: number; fibSelected?: boolean; @@ -44,11 +46,23 @@ type Props = { }; 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; @@ -82,11 +96,10 @@ function toCandleData(candles: Candle[]): CandlestickData[] { 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)', + color: 'rgba(148,163,184,0.22)', }; }); } @@ -103,6 +116,8 @@ export default function TradingChart({ ema20, bb20, showIndicators, + bucketSeconds, + seriesKey, fib, fibOpacity = 1, fibSelected = false, @@ -124,9 +139,13 @@ export default function TradingChart({ const onChartCrosshairMoveRef = useRef(onChartCrosshairMove); const onPointerEventRef = useRef(onPointerEvent); const capturedOverlayPointerRef = useRef(null); + const buildSamplesRef = useRef>(new Map()); + const buildKeyRef = useRef(null); + const lastBuildCandleStartRef = useRef(null); const seriesRef = useRef<{ candles?: ISeriesApi<'Candlestick'>; volume?: ISeriesApi<'Histogram'>; + build?: ISeriesApi<'Line'>; oracle?: ISeriesApi<'Line'>; sma20?: ISeriesApi<'Line'>; ema20?: ISeriesApi<'Line'>; @@ -225,7 +244,22 @@ export default function TradingChart({ color: 'rgba(255,255,255,0.15)', }); 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, { @@ -249,6 +283,7 @@ export default function TradingChart({ seriesRef.current = { candles: candleSeries, volume: volumeSeries, + build: buildSeries, oracle: oracleSeries, sma20: smaSeries, ema20: emaSeries, @@ -551,7 +586,7 @@ export default function TradingChart({ useEffect(() => { const s = seriesRef.current; - if (!s.candles || !s.volume) return; + if (!s.candles || !s.volume || !s.build) return; s.candles.setData(candleData); s.volume.setData(volumeData); s.oracle?.setData(oracleData); @@ -561,17 +596,131 @@ export default function TradingChart({ 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]); + }, [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 });