637 lines
20 KiB
TypeScript
637 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
CandlestickSeries,
|
|
ColorType,
|
|
HistogramSeries,
|
|
createChart,
|
|
type IChartApi,
|
|
type ISeriesApi,
|
|
type Time,
|
|
type UTCTimestamp,
|
|
} from "lightweight-charts";
|
|
import { ChevronDown } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { fetchStockChart } from "@/features/dashboard/apis/kis-stock.api";
|
|
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
|
import type {
|
|
DashboardChartTimeframe,
|
|
StockCandlePoint,
|
|
} from "@/features/dashboard/types/dashboard.types";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
|
|
const UP_COLOR = "#ef4444";
|
|
const DOWN_COLOR = "#2563eb";
|
|
|
|
// 분봉 드롭다운 옵션
|
|
const MINUTE_TIMEFRAMES: Array<{
|
|
value: DashboardChartTimeframe;
|
|
label: string;
|
|
}> = [
|
|
{ value: "1m", label: "1분" },
|
|
{ value: "30m", label: "30분" },
|
|
{ value: "1h", label: "1시간" },
|
|
];
|
|
|
|
// 일봉 이상 개별 버튼
|
|
const PERIOD_TIMEFRAMES: Array<{
|
|
value: DashboardChartTimeframe;
|
|
label: string;
|
|
}> = [
|
|
{ value: "1d", label: "일" },
|
|
{ value: "1w", label: "주" },
|
|
];
|
|
|
|
type ChartBar = {
|
|
time: UTCTimestamp;
|
|
open: number;
|
|
high: number;
|
|
low: number;
|
|
close: number;
|
|
volume: number;
|
|
};
|
|
|
|
interface StockLineChartProps {
|
|
symbol?: string;
|
|
candles: StockCandlePoint[];
|
|
credentials?: KisRuntimeCredentials | null;
|
|
}
|
|
|
|
/**
|
|
* @description TradingView 스타일 캔들 차트 + 거래량 + 무한 과거 로딩
|
|
* @see https://tradingview.github.io/lightweight-charts/tutorials/demos/realtime-updates
|
|
* @see https://tradingview.github.io/lightweight-charts/tutorials/demos/infinite-history
|
|
*/
|
|
export function StockLineChart({
|
|
symbol,
|
|
candles,
|
|
credentials,
|
|
}: StockLineChartProps) {
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const chartRef = useRef<IChartApi | null>(null);
|
|
const candleSeriesRef = useRef<ISeriesApi<"Candlestick", Time> | null>(null);
|
|
const volumeSeriesRef = useRef<ISeriesApi<"Histogram", Time> | null>(null);
|
|
|
|
const [timeframe, setTimeframe] = useState<DashboardChartTimeframe>("1d");
|
|
const [isMinuteDropdownOpen, setIsMinuteDropdownOpen] = useState(false);
|
|
const [bars, setBars] = useState<ChartBar[]>([]);
|
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
const [isChartReady, setIsChartReady] = useState(false);
|
|
const lastRealtimeKeyRef = useRef<string>("");
|
|
const loadingMoreRef = useRef(false);
|
|
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
|
|
const initialLoadCompleteRef = useRef(false);
|
|
|
|
// candles prop을 ref로 관리하여 useEffect 디펜던시에서 제거 (무한 페칭 방지)
|
|
const latestCandlesRef = useRef(candles);
|
|
useEffect(() => {
|
|
latestCandlesRef.current = candles;
|
|
}, [candles]);
|
|
|
|
const latest = bars.at(-1);
|
|
const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0;
|
|
const change = latest ? latest.close - prevClose : 0;
|
|
const changeRate = prevClose > 0 ? (change / prevClose) * 100 : 0;
|
|
const renderableBars = useMemo(() => {
|
|
const dedup = new Map<number, ChartBar>();
|
|
for (const bar of bars) {
|
|
if (
|
|
!Number.isFinite(bar.time) ||
|
|
!Number.isFinite(bar.open) ||
|
|
!Number.isFinite(bar.high) ||
|
|
!Number.isFinite(bar.low) ||
|
|
!Number.isFinite(bar.close) ||
|
|
bar.close <= 0
|
|
) {
|
|
continue;
|
|
}
|
|
dedup.set(bar.time, bar);
|
|
}
|
|
return [...dedup.values()].sort((a, b) => a.time - b.time);
|
|
}, [bars]);
|
|
|
|
const setSeriesData = useCallback((nextBars: ChartBar[]) => {
|
|
const candleSeries = candleSeriesRef.current;
|
|
const volumeSeries = volumeSeriesRef.current;
|
|
if (!candleSeries || !volumeSeries) return;
|
|
|
|
// lightweight-charts는 시간 오름차순/유효 숫자 조건이 깨지면 렌더를 멈출 수 있어
|
|
// 전달 직전 데이터를 한 번 더 정리합니다.
|
|
const safeBars = nextBars;
|
|
|
|
try {
|
|
candleSeries.setData(
|
|
safeBars.map((bar) => ({
|
|
time: bar.time,
|
|
open: bar.open,
|
|
high: bar.high,
|
|
low: bar.low,
|
|
close: bar.close,
|
|
})),
|
|
);
|
|
|
|
volumeSeries.setData(
|
|
safeBars.map((bar) => ({
|
|
time: bar.time,
|
|
value: Number.isFinite(bar.volume) ? bar.volume : 0,
|
|
color:
|
|
bar.close >= bar.open
|
|
? "rgba(239,68,68,0.45)"
|
|
: "rgba(37,99,235,0.45)",
|
|
})),
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to render chart series data:", error);
|
|
}
|
|
}, []);
|
|
|
|
const handleLoadMore = useCallback(async () => {
|
|
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current)
|
|
return;
|
|
|
|
loadingMoreRef.current = true;
|
|
setIsLoadingMore(true);
|
|
try {
|
|
const response = await fetchStockChart(
|
|
symbol,
|
|
timeframe,
|
|
credentials,
|
|
nextCursor,
|
|
);
|
|
const older = normalizeCandles(response.candles, timeframe);
|
|
setBars((prev) => mergeBars(older, prev));
|
|
setNextCursor(response.hasMore ? response.nextCursor : null);
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: "과거 차트 조회에 실패했습니다.";
|
|
toast.error(message);
|
|
} finally {
|
|
loadingMoreRef.current = false;
|
|
setIsLoadingMore(false);
|
|
}
|
|
}, [credentials, nextCursor, symbol, timeframe]);
|
|
|
|
useEffect(() => {
|
|
loadMoreHandlerRef.current = handleLoadMore;
|
|
}, [handleLoadMore]);
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container || chartRef.current) return;
|
|
|
|
const initialWidth = Math.max(container.clientWidth, 320);
|
|
const initialHeight = Math.max(container.clientHeight, 340);
|
|
|
|
const chart = createChart(container, {
|
|
width: initialWidth,
|
|
height: initialHeight,
|
|
layout: {
|
|
background: { type: ColorType.Solid, color: "#ffffff" },
|
|
textColor: "#475569",
|
|
attributionLogo: true,
|
|
},
|
|
localization: {
|
|
locale: "ko-KR",
|
|
},
|
|
rightPriceScale: {
|
|
borderColor: "#e2e8f0",
|
|
scaleMargins: {
|
|
top: 0.08,
|
|
bottom: 0.24,
|
|
},
|
|
},
|
|
grid: {
|
|
vertLines: { color: "#edf1f5" },
|
|
horzLines: { color: "#edf1f5" },
|
|
},
|
|
crosshair: {
|
|
vertLine: { color: "#94a3b8", width: 1, style: 2 },
|
|
horzLine: { color: "#94a3b8", width: 1, style: 2 },
|
|
},
|
|
timeScale: {
|
|
borderColor: "#e2e8f0",
|
|
timeVisible: true,
|
|
secondsVisible: false,
|
|
rightOffset: 2,
|
|
},
|
|
handleScroll: {
|
|
mouseWheel: true,
|
|
pressedMouseMove: true,
|
|
},
|
|
handleScale: {
|
|
mouseWheel: true,
|
|
pinch: true,
|
|
axisPressedMouseMove: true,
|
|
},
|
|
});
|
|
|
|
const candleSeries = chart.addSeries(CandlestickSeries, {
|
|
upColor: UP_COLOR,
|
|
downColor: DOWN_COLOR,
|
|
wickUpColor: UP_COLOR,
|
|
wickDownColor: DOWN_COLOR,
|
|
borderUpColor: UP_COLOR,
|
|
borderDownColor: DOWN_COLOR,
|
|
priceLineVisible: true,
|
|
lastValueVisible: true,
|
|
});
|
|
|
|
const volumeSeries = chart.addSeries(HistogramSeries, {
|
|
priceScaleId: "volume",
|
|
priceLineVisible: false,
|
|
lastValueVisible: false,
|
|
base: 0,
|
|
});
|
|
|
|
chart.priceScale("volume").applyOptions({
|
|
scaleMargins: {
|
|
top: 0.78,
|
|
bottom: 0,
|
|
},
|
|
borderVisible: false,
|
|
});
|
|
|
|
// 스크롤 디바운스 타이머
|
|
let scrollTimeout: NodeJS.Timeout | null = null;
|
|
|
|
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
|
|
if (!range) return;
|
|
|
|
// 초기 로딩 완료 후에만 무한 스크롤 트리거
|
|
// range.from이 0에 가까워지면(과거 데이터 필요) 로딩
|
|
if (range.from < 10 && initialLoadCompleteRef.current) {
|
|
if (scrollTimeout) clearTimeout(scrollTimeout);
|
|
|
|
scrollTimeout = setTimeout(() => {
|
|
void loadMoreHandlerRef.current();
|
|
}, 300); // 300ms 디바운스
|
|
}
|
|
});
|
|
|
|
chartRef.current = chart;
|
|
candleSeriesRef.current = candleSeries;
|
|
volumeSeriesRef.current = volumeSeries;
|
|
setIsChartReady(true);
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
const nextWidth = Math.max(container.clientWidth, 320);
|
|
const nextHeight = Math.max(container.clientHeight, 340);
|
|
chart.resize(nextWidth, nextHeight);
|
|
});
|
|
resizeObserver.observe(container);
|
|
|
|
// 첫 렌더 직후 부모 레이아웃 계산이 끝난 시점에 한 번 더 사이즈를 맞춥니다.
|
|
const rafId = window.requestAnimationFrame(() => {
|
|
const nextWidth = Math.max(container.clientWidth, 320);
|
|
const nextHeight = Math.max(container.clientHeight, 340);
|
|
chart.resize(nextWidth, nextHeight);
|
|
});
|
|
|
|
return () => {
|
|
window.cancelAnimationFrame(rafId);
|
|
resizeObserver.disconnect();
|
|
chart.remove();
|
|
chartRef.current = null;
|
|
candleSeriesRef.current = null;
|
|
volumeSeriesRef.current = null;
|
|
setIsChartReady(false);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (symbol && credentials) return;
|
|
setBars(normalizeCandles(candles, "1d"));
|
|
setNextCursor(null);
|
|
}, [candles, credentials, symbol]);
|
|
|
|
useEffect(() => {
|
|
if (!symbol || !credentials) return;
|
|
|
|
// 초기 로딩 보호 플래그 초기화 (타임프레임/종목 변경 시)
|
|
initialLoadCompleteRef.current = false;
|
|
|
|
let disposed = false;
|
|
const load = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await fetchStockChart(symbol, timeframe, credentials);
|
|
if (disposed) return;
|
|
|
|
const normalized = normalizeCandles(response.candles, timeframe);
|
|
setBars(normalized);
|
|
setNextCursor(response.hasMore ? response.nextCursor : null);
|
|
|
|
// 초기 로딩 완료 후 500ms 지연 후 무한 스크롤 활성화
|
|
// (fitContent 후 range가 0이 되어 즉시 트리거되는 것 방지)
|
|
setTimeout(() => {
|
|
if (!disposed) {
|
|
initialLoadCompleteRef.current = true;
|
|
}
|
|
}, 500);
|
|
} catch (error) {
|
|
if (disposed) return;
|
|
const message =
|
|
error instanceof Error ? error.message : "차트 조회에 실패했습니다.";
|
|
toast.error(message);
|
|
// 에러 발생 시 fallback으로 props로 전달된 candles 사용
|
|
setBars(normalizeCandles(latestCandlesRef.current, timeframe));
|
|
setNextCursor(null);
|
|
} finally {
|
|
if (!disposed) setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
void load();
|
|
return () => {
|
|
disposed = true;
|
|
};
|
|
}, [credentials, symbol, timeframe]);
|
|
|
|
useEffect(() => {
|
|
if (!isChartReady) return;
|
|
setSeriesData(renderableBars);
|
|
|
|
// 초기 로딩 시에만 fitContent 수행
|
|
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
|
|
chartRef.current?.timeScale().fitContent();
|
|
}
|
|
}, [isChartReady, renderableBars, setSeriesData]);
|
|
|
|
const latestRealtime = useMemo(() => candles.at(-1), [candles]);
|
|
useEffect(() => {
|
|
if (!latestRealtime || bars.length === 0) return;
|
|
if (timeframe === "1w" && !latestRealtime.timestamp && !latestRealtime.time)
|
|
return;
|
|
|
|
const key = `${latestRealtime.time}-${latestRealtime.price}-${latestRealtime.volume ?? 0}`;
|
|
if (lastRealtimeKeyRef.current === key) return;
|
|
lastRealtimeKeyRef.current = key;
|
|
|
|
const nextBar = convertRealtimePointToBar(latestRealtime, timeframe);
|
|
if (!nextBar) return;
|
|
|
|
setBars((prev) => upsertRealtimeBar(prev, nextBar));
|
|
}, [bars.length, candles, latestRealtime, timeframe]);
|
|
|
|
// 상태 메시지 결정 (오버레이로 표시)
|
|
const statusMessage = (() => {
|
|
if (isLoading && bars.length === 0)
|
|
return "차트 데이터를 불러오는 중입니다.";
|
|
if (bars.length === 0) return "차트 데이터가 없습니다.";
|
|
if (renderableBars.length === 0)
|
|
return "차트 데이터 형식이 올바르지 않아 렌더링할 수 없습니다.";
|
|
return null;
|
|
})();
|
|
|
|
return (
|
|
<div className="flex h-full min-h-[340px] flex-col bg-white">
|
|
{/* ========== CHART TOOLBAR ========== */}
|
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-2 py-2 sm:px-3">
|
|
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
|
|
{/* 분봉 드롭다운 */}
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsMinuteDropdownOpen((v) => !v)}
|
|
onBlur={() =>
|
|
setTimeout(() => setIsMinuteDropdownOpen(false), 200)
|
|
}
|
|
className={cn(
|
|
"flex items-center gap-1 rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
|
|
MINUTE_TIMEFRAMES.some((t) => t.value === timeframe) &&
|
|
"bg-brand-100 font-semibold text-brand-700",
|
|
)}
|
|
>
|
|
{MINUTE_TIMEFRAMES.find((t) => t.value === timeframe)?.label ??
|
|
"분봉"}
|
|
<ChevronDown className="h-3 w-3" />
|
|
</button>
|
|
{isMinuteDropdownOpen && (
|
|
<div className="absolute left-0 top-full z-10 mt-1 rounded border border-slate-200 bg-white shadow-lg">
|
|
{MINUTE_TIMEFRAMES.map((item) => (
|
|
<button
|
|
key={item.value}
|
|
type="button"
|
|
onClick={() => {
|
|
setTimeframe(item.value);
|
|
setIsMinuteDropdownOpen(false);
|
|
}}
|
|
className={cn(
|
|
"block w-full whitespace-nowrap px-3 py-1.5 text-left hover:bg-slate-100",
|
|
timeframe === item.value &&
|
|
"bg-brand-50 font-semibold text-brand-700",
|
|
)}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 일/주 버튼 */}
|
|
{PERIOD_TIMEFRAMES.map((item) => (
|
|
<button
|
|
key={item.value}
|
|
type="button"
|
|
onClick={() => setTimeframe(item.value)}
|
|
className={cn(
|
|
"rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
|
|
timeframe === item.value &&
|
|
"bg-brand-100 font-semibold text-brand-700",
|
|
)}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
{isLoadingMore && (
|
|
<span className="ml-2 text-[11px] text-muted-foreground">
|
|
과거 데이터 로딩중...
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-[11px] text-slate-600 sm:text-xs">
|
|
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)}{" "}
|
|
L {formatPrice(latest?.low ?? 0)} C{" "}
|
|
<span className={cn(change >= 0 ? "text-red-600" : "text-blue-600")}>
|
|
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)}
|
|
)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ========== CHART BODY ========== */}
|
|
<div className="relative min-h-0 flex-1 overflow-hidden">
|
|
{/* 차트 캔버스 컨테이너 - 항상 렌더링 */}
|
|
<div ref={containerRef} className="h-full w-full" />
|
|
|
|
{/* 상태 메시지 오버레이 */}
|
|
{statusMessage && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-white/80 text-sm text-muted-foreground">
|
|
{statusMessage}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function normalizeCandles(
|
|
candles: StockCandlePoint[],
|
|
timeframe: DashboardChartTimeframe,
|
|
) {
|
|
const rows = candles
|
|
.map((item) => convertCandleToBar(item, timeframe))
|
|
.filter((item): item is ChartBar => Boolean(item));
|
|
return mergeBars([], rows);
|
|
}
|
|
|
|
function convertCandleToBar(
|
|
candle: StockCandlePoint,
|
|
timeframe: DashboardChartTimeframe,
|
|
): ChartBar | null {
|
|
const close = candle.close ?? candle.price;
|
|
if (!Number.isFinite(close) || close <= 0) return null;
|
|
|
|
const open = candle.open ?? close;
|
|
const high = candle.high ?? Math.max(open, close);
|
|
const low = candle.low ?? Math.min(open, close);
|
|
const volume = candle.volume ?? 0;
|
|
const time = resolveBarTimestamp(candle, timeframe);
|
|
if (!time) return null;
|
|
|
|
return {
|
|
time,
|
|
open,
|
|
high: Math.max(high, open, close),
|
|
low: Math.min(low, open, close),
|
|
close,
|
|
volume,
|
|
};
|
|
}
|
|
|
|
function resolveBarTimestamp(
|
|
candle: StockCandlePoint,
|
|
timeframe: DashboardChartTimeframe,
|
|
): UTCTimestamp | null {
|
|
if (
|
|
typeof candle.timestamp === "number" &&
|
|
Number.isFinite(candle.timestamp)
|
|
) {
|
|
return adjustTimestampForTimeframe(candle.timestamp, timeframe);
|
|
}
|
|
|
|
const timeText = typeof candle.time === "string" ? candle.time.trim() : "";
|
|
if (!timeText) return null;
|
|
|
|
if (/^\d{2}\/\d{2}$/.test(timeText)) {
|
|
const [mm, dd] = timeText.split("/");
|
|
const year = new Date().getFullYear();
|
|
const d = new Date(`${year}-${mm}-${dd}T09:00:00+09:00`);
|
|
return adjustTimestampForTimeframe(
|
|
Math.floor(d.getTime() / 1000),
|
|
timeframe,
|
|
);
|
|
}
|
|
|
|
if (/^\d{2}:\d{2}(:\d{2})?$/.test(timeText)) {
|
|
const [hh, mi, ss] = timeText.split(":");
|
|
const now = new Date();
|
|
const y = now.getFullYear();
|
|
const m = `${now.getMonth() + 1}`.padStart(2, "0");
|
|
const d = `${now.getDate()}`.padStart(2, "0");
|
|
const ts = new Date(
|
|
`${y}-${m}-${d}T${hh}:${mi}:${ss ?? "00"}+09:00`,
|
|
).getTime();
|
|
return adjustTimestampForTimeframe(Math.floor(ts / 1000), timeframe);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function adjustTimestampForTimeframe(
|
|
timestamp: number,
|
|
timeframe: DashboardChartTimeframe,
|
|
): UTCTimestamp {
|
|
const date = new Date(timestamp * 1000);
|
|
if (timeframe === "30m" || timeframe === "1h") {
|
|
const bucketMinutes = timeframe === "30m" ? 30 : 60;
|
|
const mins = date.getUTCMinutes();
|
|
const aligned = Math.floor(mins / bucketMinutes) * bucketMinutes;
|
|
date.setUTCMinutes(aligned, 0, 0);
|
|
} else if (timeframe === "1d") {
|
|
date.setUTCHours(0, 0, 0, 0);
|
|
} else if (timeframe === "1w") {
|
|
const day = date.getUTCDay();
|
|
const diff = day === 0 ? -6 : 1 - day;
|
|
date.setUTCDate(date.getUTCDate() + diff);
|
|
date.setUTCHours(0, 0, 0, 0);
|
|
}
|
|
return Math.floor(date.getTime() / 1000) as UTCTimestamp;
|
|
}
|
|
|
|
function mergeBars(left: ChartBar[], right: ChartBar[]) {
|
|
const map = new Map<number, ChartBar>();
|
|
for (const bar of [...left, ...right]) {
|
|
const prev = map.get(bar.time);
|
|
if (!prev) {
|
|
map.set(bar.time, bar);
|
|
continue;
|
|
}
|
|
|
|
map.set(bar.time, {
|
|
time: bar.time,
|
|
open: prev.open,
|
|
high: Math.max(prev.high, bar.high),
|
|
low: Math.min(prev.low, bar.low),
|
|
close: bar.close,
|
|
volume: Math.max(prev.volume, bar.volume),
|
|
});
|
|
}
|
|
|
|
return [...map.values()].sort((a, b) => a.time - b.time);
|
|
}
|
|
|
|
function convertRealtimePointToBar(
|
|
point: StockCandlePoint,
|
|
timeframe: DashboardChartTimeframe,
|
|
) {
|
|
return convertCandleToBar(point, timeframe);
|
|
}
|
|
|
|
function upsertRealtimeBar(prev: ChartBar[], incoming: ChartBar) {
|
|
if (prev.length === 0) return [incoming];
|
|
const last = prev[prev.length - 1];
|
|
if (incoming.time > last.time) {
|
|
return [...prev, incoming];
|
|
}
|
|
if (incoming.time < last.time) return prev;
|
|
|
|
return [
|
|
...prev.slice(0, -1),
|
|
{
|
|
time: last.time,
|
|
open: last.open,
|
|
high: Math.max(last.high, incoming.high),
|
|
low: Math.min(last.low, incoming.low),
|
|
close: incoming.close,
|
|
volume: Math.max(last.volume, incoming.volume),
|
|
},
|
|
];
|
|
}
|
|
|
|
function formatPrice(value: number) {
|
|
return KRW_FORMATTER.format(Math.round(value));
|
|
}
|
|
|
|
function formatSignedPercent(value: number) {
|
|
const sign = value > 0 ? "+" : "";
|
|
return `${sign}${value.toFixed(2)}%`;
|
|
}
|