feat(chart): add candle build indicator line
This commit is contained in:
@@ -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<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'>;
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user