임시커밋
This commit is contained in:
692
features/trade/components/chart/StockLineChart.tsx
Normal file
692
features/trade/components/chart/StockLineChart.tsx
Normal file
@@ -0,0 +1,692 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CandlestickSeries,
|
||||
ColorType,
|
||||
HistogramSeries,
|
||||
createChart,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
type Time,
|
||||
} from "lightweight-charts";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
import { fetchStockChart } from "@/features/trade/apis/kis-stock.api";
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardChartTimeframe,
|
||||
DashboardRealtimeTradeTick,
|
||||
StockCandlePoint,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type ChartBar,
|
||||
formatKstCrosshairTime,
|
||||
formatKstTickMark,
|
||||
formatPrice,
|
||||
formatSignedPercent,
|
||||
isMinuteTimeframe,
|
||||
mergeBars,
|
||||
normalizeCandles,
|
||||
toRealtimeTickBar,
|
||||
upsertRealtimeBar,
|
||||
} from "./chart-utils";
|
||||
|
||||
const UP_COLOR = "#ef4444";
|
||||
const MINUTE_SYNC_INTERVAL_MS = 5000;
|
||||
const REALTIME_STALE_THRESHOLD_MS = 12000;
|
||||
|
||||
interface ChartPalette {
|
||||
backgroundColor: string;
|
||||
downColor: string;
|
||||
volumeDownColor: string;
|
||||
textColor: string;
|
||||
borderColor: string;
|
||||
gridColor: string;
|
||||
crosshairColor: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CHART_PALETTE: ChartPalette = {
|
||||
backgroundColor: "#ffffff",
|
||||
downColor: "#2563eb",
|
||||
volumeDownColor: "rgba(37, 99, 235, 0.45)",
|
||||
textColor: "#6d28d9",
|
||||
borderColor: "#e9d5ff",
|
||||
gridColor: "#f3e8ff",
|
||||
crosshairColor: "#c084fc",
|
||||
};
|
||||
|
||||
function readCssVar(name: string, fallback: string) {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
function getChartPaletteFromCssVars(themeMode: "light" | "dark"): ChartPalette {
|
||||
const isDark = themeMode === "dark";
|
||||
const backgroundVar = isDark
|
||||
? "--brand-chart-background-dark"
|
||||
: "--brand-chart-background-light";
|
||||
const textVar = isDark ? "--brand-chart-text-dark" : "--brand-chart-text-light";
|
||||
const borderVar = isDark ? "--brand-chart-border-dark" : "--brand-chart-border-light";
|
||||
const gridVar = isDark ? "--brand-chart-grid-dark" : "--brand-chart-grid-light";
|
||||
const crosshairVar = isDark
|
||||
? "--brand-chart-crosshair-dark"
|
||||
: "--brand-chart-crosshair-light";
|
||||
|
||||
return {
|
||||
backgroundColor: readCssVar(backgroundVar, DEFAULT_CHART_PALETTE.backgroundColor),
|
||||
downColor: readCssVar("--brand-chart-down", DEFAULT_CHART_PALETTE.downColor),
|
||||
volumeDownColor: readCssVar(
|
||||
"--brand-chart-volume-down",
|
||||
DEFAULT_CHART_PALETTE.volumeDownColor,
|
||||
),
|
||||
textColor: readCssVar(textVar, DEFAULT_CHART_PALETTE.textColor),
|
||||
borderColor: readCssVar(borderVar, DEFAULT_CHART_PALETTE.borderColor),
|
||||
gridColor: readCssVar(gridVar, DEFAULT_CHART_PALETTE.gridColor),
|
||||
crosshairColor: readCssVar(
|
||||
crosshairVar,
|
||||
DEFAULT_CHART_PALETTE.crosshairColor,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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: "주" },
|
||||
];
|
||||
|
||||
interface StockLineChartProps {
|
||||
symbol?: string;
|
||||
candles: StockCandlePoint[];
|
||||
credentials?: KisRuntimeCredentials | null;
|
||||
latestTick?: DashboardRealtimeTradeTick | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description TradingView 스타일 캔들 차트를 렌더링하고, timeframe별 KIS 차트 API를 조회합니다.
|
||||
* @see features/trade/apis/kis-stock.api.ts fetchStockChart
|
||||
* @see lib/kis/domestic.ts getDomesticChart
|
||||
*/
|
||||
export function StockLineChart({
|
||||
symbol,
|
||||
candles,
|
||||
credentials,
|
||||
latestTick,
|
||||
}: StockLineChartProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
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 lastRealtimeAppliedAtRef = useRef(0);
|
||||
const chartPaletteRef = useRef<ChartPalette>(DEFAULT_CHART_PALETTE);
|
||||
const renderableBarsRef = useRef<ChartBar[]>([]);
|
||||
|
||||
const activeThemeMode: "light" | "dark" =
|
||||
resolvedTheme === "dark"
|
||||
? "dark"
|
||||
: resolvedTheme === "light"
|
||||
? "light"
|
||||
: typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark")
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
// 복수 이벤트에서 중복 로드를 막기 위한 ref 상태
|
||||
const loadingMoreRef = useRef(false);
|
||||
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
|
||||
// API 오류 시 fallback 용도로 유지
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
renderableBarsRef.current = renderableBars;
|
||||
}, [renderableBars]);
|
||||
|
||||
/**
|
||||
* @description lightweight-charts 시리즈 데이터에 안전한 OHLCV 값을 주입합니다.
|
||||
* @see features/trade/components/chart/StockLineChart.tsx renderableBars useMemo
|
||||
*/
|
||||
const setSeriesData = useCallback((nextBars: ChartBar[]) => {
|
||||
const candleSeries = candleSeriesRef.current;
|
||||
const volumeSeries = volumeSeriesRef.current;
|
||||
if (!candleSeries || !volumeSeries) return;
|
||||
|
||||
try {
|
||||
candleSeries.setData(
|
||||
nextBars.map((bar) => ({
|
||||
time: bar.time,
|
||||
open: bar.open,
|
||||
high: bar.high,
|
||||
low: bar.low,
|
||||
close: bar.close,
|
||||
})),
|
||||
);
|
||||
|
||||
volumeSeries.setData(
|
||||
nextBars.map((bar) => ({
|
||||
time: bar.time,
|
||||
value: Number.isFinite(bar.volume) ? bar.volume : 0,
|
||||
color:
|
||||
bar.close >= bar.open
|
||||
? "rgba(239,68,68,0.45)"
|
||||
: chartPaletteRef.current.volumeDownColor,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to render chart series data:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* @description 좌측 스크롤 시 cursor 기반 과거 캔들을 추가 로드합니다.
|
||||
* @see lib/kis/domestic.ts getDomesticChart cursor
|
||||
*/
|
||||
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 olderBars = normalizeCandles(response.candles, timeframe);
|
||||
setBars((prev) => mergeBars(olderBars, 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(() => {
|
||||
lastRealtimeKeyRef.current = "";
|
||||
lastRealtimeAppliedAtRef.current = 0;
|
||||
}, [symbol, timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || chartRef.current) return;
|
||||
|
||||
// 브랜드 색상은 globals.css의 CSS 변수에서 읽어 차트(canvas)에도 동일하게 적용합니다.
|
||||
const palette = getChartPaletteFromCssVars(activeThemeMode);
|
||||
chartPaletteRef.current = palette;
|
||||
|
||||
const chart = createChart(container, {
|
||||
width: Math.max(container.clientWidth, 320),
|
||||
height: Math.max(container.clientHeight, 340),
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: palette.backgroundColor },
|
||||
textColor: palette.textColor,
|
||||
attributionLogo: true,
|
||||
},
|
||||
localization: {
|
||||
locale: "ko-KR",
|
||||
timeFormatter: formatKstCrosshairTime,
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: palette.borderColor,
|
||||
scaleMargins: {
|
||||
top: 0.08,
|
||||
bottom: 0.24,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: palette.gridColor },
|
||||
horzLines: { color: palette.gridColor },
|
||||
},
|
||||
crosshair: {
|
||||
vertLine: { color: palette.crosshairColor, width: 1, style: 2 },
|
||||
horzLine: { color: palette.crosshairColor, width: 1, style: 2 },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: palette.borderColor,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
rightOffset: 2,
|
||||
tickMarkFormatter: formatKstTickMark,
|
||||
},
|
||||
handleScroll: {
|
||||
mouseWheel: true,
|
||||
pressedMouseMove: true,
|
||||
},
|
||||
handleScale: {
|
||||
mouseWheel: true,
|
||||
pinch: true,
|
||||
axisPressedMouseMove: true,
|
||||
},
|
||||
});
|
||||
|
||||
const candleSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: UP_COLOR,
|
||||
downColor: palette.downColor,
|
||||
wickUpColor: UP_COLOR,
|
||||
wickDownColor: palette.downColor,
|
||||
borderUpColor: UP_COLOR,
|
||||
borderDownColor: palette.downColor,
|
||||
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: number | undefined;
|
||||
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
|
||||
if (!range || !initialLoadCompleteRef.current) return;
|
||||
if (range.from >= 10) return;
|
||||
|
||||
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
|
||||
scrollTimeout = window.setTimeout(() => {
|
||||
void loadMoreHandlerRef.current();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
chartRef.current = chart;
|
||||
candleSeriesRef.current = candleSeries;
|
||||
volumeSeriesRef.current = volumeSeries;
|
||||
setIsChartReady(true);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
chart.resize(
|
||||
Math.max(container.clientWidth, 320),
|
||||
Math.max(container.clientHeight, 340),
|
||||
);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
chart.resize(
|
||||
Math.max(container.clientWidth, 320),
|
||||
Math.max(container.clientHeight, 340),
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
|
||||
window.cancelAnimationFrame(rafId);
|
||||
resizeObserver.disconnect();
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
candleSeriesRef.current = null;
|
||||
volumeSeriesRef.current = null;
|
||||
setIsChartReady(false);
|
||||
};
|
||||
}, [activeThemeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const chart = chartRef.current;
|
||||
const candleSeries = candleSeriesRef.current;
|
||||
if (!chart || !candleSeries) return;
|
||||
|
||||
const palette = getChartPaletteFromCssVars(activeThemeMode);
|
||||
chartPaletteRef.current = palette;
|
||||
|
||||
chart.applyOptions({
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: palette.backgroundColor },
|
||||
textColor: palette.textColor,
|
||||
},
|
||||
rightPriceScale: { borderColor: palette.borderColor },
|
||||
grid: {
|
||||
vertLines: { color: palette.gridColor },
|
||||
horzLines: { color: palette.gridColor },
|
||||
},
|
||||
crosshair: {
|
||||
vertLine: { color: palette.crosshairColor, width: 1, style: 2 },
|
||||
horzLine: { color: palette.crosshairColor, width: 1, style: 2 },
|
||||
},
|
||||
timeScale: { borderColor: palette.borderColor },
|
||||
});
|
||||
|
||||
candleSeries.applyOptions({
|
||||
downColor: palette.downColor,
|
||||
wickDownColor: palette.downColor,
|
||||
borderDownColor: palette.downColor,
|
||||
});
|
||||
|
||||
setSeriesData(renderableBarsRef.current);
|
||||
}, [activeThemeMode, setSeriesData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (symbol && credentials) return;
|
||||
|
||||
// 인증 전/종목 미선택 상태는 overview 캔들로 fallback
|
||||
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 firstPage = await fetchStockChart(symbol, timeframe, credentials);
|
||||
if (disposed) return;
|
||||
|
||||
let mergedBars = normalizeCandles(firstPage.candles, timeframe);
|
||||
let resolvedNextCursor = firstPage.hasMore ? firstPage.nextCursor : null;
|
||||
|
||||
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
|
||||
if (
|
||||
isMinuteTimeframe(timeframe) &&
|
||||
firstPage.hasMore &&
|
||||
firstPage.nextCursor
|
||||
) {
|
||||
let minuteCursor: string | null = firstPage.nextCursor;
|
||||
let extraPageCount = 0;
|
||||
|
||||
while (minuteCursor && extraPageCount < 2) {
|
||||
try {
|
||||
const olderPage = await fetchStockChart(
|
||||
symbol,
|
||||
timeframe,
|
||||
credentials,
|
||||
minuteCursor,
|
||||
);
|
||||
|
||||
const olderBars = normalizeCandles(olderPage.candles, timeframe);
|
||||
mergedBars = mergeBars(olderBars, mergedBars);
|
||||
resolvedNextCursor = olderPage.hasMore ? olderPage.nextCursor : null;
|
||||
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null;
|
||||
extraPageCount += 1;
|
||||
} catch {
|
||||
// 추가 페이지 실패는 치명적이지 않으므로 현재 데이터는 유지합니다.
|
||||
minuteCursor = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setBars(mergedBars);
|
||||
setNextCursor(resolvedNextCursor);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!disposed) initialLoadCompleteRef.current = true;
|
||||
}, 350);
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "차트 조회 중 오류가 발생했습니다.";
|
||||
toast.error(message);
|
||||
|
||||
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);
|
||||
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
}
|
||||
}, [isChartReady, renderableBars, setSeriesData]);
|
||||
|
||||
/**
|
||||
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
|
||||
* @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!latestTick) return;
|
||||
if (bars.length === 0) return;
|
||||
|
||||
const dedupeKey = `${timeframe}:${latestTick.tickTime}:${latestTick.price}:${latestTick.tradeVolume}`;
|
||||
if (lastRealtimeKeyRef.current === dedupeKey) return;
|
||||
|
||||
const realtimeBar = toRealtimeTickBar(latestTick, timeframe);
|
||||
if (!realtimeBar) return;
|
||||
|
||||
lastRealtimeKeyRef.current = dedupeKey;
|
||||
lastRealtimeAppliedAtRef.current = Date.now();
|
||||
setBars((prev) => upsertRealtimeBar(prev, realtimeBar));
|
||||
}, [bars.length, latestTick, timeframe]);
|
||||
|
||||
/**
|
||||
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
|
||||
* @see features/trade/apis/kis-stock.api.ts fetchStockChart
|
||||
* @see lib/kis/domestic.ts getDomesticChart
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!symbol || !credentials) return;
|
||||
if (!isMinuteTimeframe(timeframe)) return;
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const syncLatestMinuteBars = async () => {
|
||||
const now = Date.now();
|
||||
const isRealtimeFresh =
|
||||
now - lastRealtimeAppliedAtRef.current < REALTIME_STALE_THRESHOLD_MS;
|
||||
if (isRealtimeFresh) return;
|
||||
|
||||
try {
|
||||
const response = await fetchStockChart(symbol, timeframe, credentials);
|
||||
if (disposed) return;
|
||||
|
||||
const latestPageBars = normalizeCandles(response.candles, timeframe);
|
||||
const recentBars = latestPageBars.slice(-10);
|
||||
if (recentBars.length === 0) return;
|
||||
|
||||
setBars((prev) => mergeBars(prev, recentBars));
|
||||
} catch {
|
||||
// 폴링 실패는 치명적이지 않으므로 조용히 다음 주기에서 재시도합니다.
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void syncLatestMinuteBars();
|
||||
}, MINUTE_SYNC_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [credentials, symbol, 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 dark:bg-brand-900/10">
|
||||
{/* ========== CHART TOOLBAR ========== */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-brand-100 bg-muted/20 px-2 py-2 sm:px-3 dark:border-brand-800/45 dark:bg-brand-900/35">
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMinuteDropdownOpen((prev) => !prev)}
|
||||
onBlur={() =>
|
||||
window.setTimeout(() => setIsMinuteDropdownOpen(false), 200)
|
||||
}
|
||||
className={cn(
|
||||
"relative flex items-center gap-1 border-b-2 border-transparent px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground dark:text-brand-100/72 dark:hover:text-brand-50",
|
||||
MINUTE_TIMEFRAMES.some((item) => item.value === timeframe) &&
|
||||
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
|
||||
)}
|
||||
>
|
||||
{MINUTE_TIMEFRAMES.find((item) => item.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-brand-100 bg-white shadow-lg dark:border-brand-700/45 dark:bg-[#1a1624]">
|
||||
{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-brand-50 dark:text-brand-100 dark:hover:bg-brand-800/35",
|
||||
timeframe === item.value &&
|
||||
"bg-brand-50 font-semibold text-brand-700 dark:bg-brand-700/30 dark:text-brand-100",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{PERIOD_TIMEFRAMES.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => setTimeframe(item.value)}
|
||||
className={cn(
|
||||
"relative border-b-2 border-transparent px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground dark:text-brand-100/72 dark:hover:text-brand-50",
|
||||
timeframe === item.value &&
|
||||
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{isLoadingMore && (
|
||||
<span className="ml-2 text-[11px] text-muted-foreground dark:text-brand-200/70">
|
||||
과거 데이터 로딩 중...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground dark:text-brand-100/85 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 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{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 dark:bg-background/90 dark:text-brand-100/80">
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
348
features/trade/components/chart/chart-utils.ts
Normal file
348
features/trade/components/chart/chart-utils.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* @file chart-utils.ts
|
||||
* @description StockLineChart에서 사용하는 유틸리티 함수 모음
|
||||
*/
|
||||
|
||||
import type {
|
||||
TickMarkType,
|
||||
Time,
|
||||
UTCTimestamp,
|
||||
} from "lightweight-charts";
|
||||
import type {
|
||||
DashboardChartTimeframe,
|
||||
DashboardRealtimeTradeTick,
|
||||
StockCandlePoint,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
|
||||
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
|
||||
const KST_TIME_ZONE = "Asia/Seoul";
|
||||
const KST_TIME_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const KST_TIME_SECONDS_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const KST_DATE_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const KST_MONTH_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
month: "short",
|
||||
});
|
||||
const KST_YEAR_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
year: "numeric",
|
||||
});
|
||||
const KST_CROSSHAIR_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────────────
|
||||
|
||||
export type ChartBar = {
|
||||
time: UTCTimestamp;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
};
|
||||
|
||||
// ─── StockCandlePoint → ChartBar 변환 ─────────────────────
|
||||
|
||||
/**
|
||||
* candles 배열을 ChartBar 배열로 정규화 (무효값 필터 + 병합 + 정렬)
|
||||
*/
|
||||
export function normalizeCandles(
|
||||
candles: StockCandlePoint[],
|
||||
timeframe: DashboardChartTimeframe,
|
||||
): ChartBar[] {
|
||||
const rows = candles
|
||||
.map((c) => convertCandleToBar(c, timeframe))
|
||||
.filter((b): b is ChartBar => Boolean(b));
|
||||
return mergeBars([], rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 candle → ChartBar 변환. 유효하지 않으면 null
|
||||
*/
|
||||
export 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 {
|
||||
// timestamp 필드가 있으면 우선 사용
|
||||
if (
|
||||
typeof candle.timestamp === "number" &&
|
||||
Number.isFinite(candle.timestamp)
|
||||
) {
|
||||
return alignTimestamp(candle.timestamp, timeframe);
|
||||
}
|
||||
|
||||
const text = typeof candle.time === "string" ? candle.time.trim() : "";
|
||||
if (!text) return null;
|
||||
|
||||
// "MM/DD" 형식 (일봉)
|
||||
if (/^\d{2}\/\d{2}$/.test(text)) {
|
||||
const [mm, dd] = text.split("/");
|
||||
const year = new Date().getFullYear();
|
||||
const ts = Math.floor(
|
||||
new Date(`${year}-${mm}-${dd}T09:00:00+09:00`).getTime() / 1000,
|
||||
);
|
||||
return alignTimestamp(ts, timeframe);
|
||||
}
|
||||
|
||||
// "HH:MM" 또는 "HH:MM:SS" 형식 (분봉)
|
||||
if (/^\d{2}:\d{2}(:\d{2})?$/.test(text)) {
|
||||
const [hh, mi, ss] = text.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 = Math.floor(
|
||||
new Date(`${y}-${m}-${d}T${hh}:${mi}:${ss ?? "00"}+09:00`).getTime() /
|
||||
1000,
|
||||
);
|
||||
return alignTimestamp(ts, timeframe);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임스탬프를 타임프레임 버킷 경계에 정렬
|
||||
* - 1m: 그대로
|
||||
* - 30m/1h: 분 단위를 버킷에 정렬
|
||||
* - 1d: 00:00:00
|
||||
* - 1w: 월요일 00:00:00
|
||||
*/
|
||||
function alignTimestamp(
|
||||
timestamp: number,
|
||||
timeframe: DashboardChartTimeframe,
|
||||
): UTCTimestamp {
|
||||
const d = new Date(timestamp * 1000);
|
||||
|
||||
if (timeframe === "30m" || timeframe === "1h") {
|
||||
const bucket = timeframe === "30m" ? 30 : 60;
|
||||
d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0);
|
||||
} else if (timeframe === "1d") {
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
} else if (timeframe === "1w") {
|
||||
const day = d.getUTCDay();
|
||||
d.setUTCDate(d.getUTCDate() + (day === 0 ? -6 : 1 - day));
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
return Math.floor(d.getTime() / 1000) as UTCTimestamp;
|
||||
}
|
||||
|
||||
// ─── 봉 병합 ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 두 ChartBar 배열을 시간 기준으로 병합. 같은 시간대는 OHLCV 통합
|
||||
*/
|
||||
export function mergeBars(left: ChartBar[], right: ChartBar[]): 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 봉 업데이트: 같은 시간이면 기존 봉에 병합, 새 시간이면 추가
|
||||
*/
|
||||
export function upsertRealtimeBar(
|
||||
prev: ChartBar[],
|
||||
incoming: ChartBar,
|
||||
): 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),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 체결 틱을 차트용 ChartBar로 변환합니다. (KST 날짜 + tickTime 기준)
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
|
||||
* @see features/trade/components/chart/StockLineChart.tsx 실시간 캔들 반영
|
||||
*/
|
||||
export function toRealtimeTickBar(
|
||||
tick: DashboardRealtimeTradeTick,
|
||||
timeframe: DashboardChartTimeframe,
|
||||
now = new Date(),
|
||||
): ChartBar | null {
|
||||
if (!Number.isFinite(tick.price) || tick.price <= 0) return null;
|
||||
|
||||
const hhmmss = normalizeTickTime(tick.tickTime);
|
||||
if (!hhmmss) return null;
|
||||
|
||||
const ymd = getKstYmd(now);
|
||||
const baseTimestamp = toKstTimestamp(ymd, hhmmss);
|
||||
const alignedTimestamp = alignTimestamp(baseTimestamp, timeframe);
|
||||
const minuteFrame = isMinuteTimeframe(timeframe);
|
||||
|
||||
return {
|
||||
time: alignedTimestamp,
|
||||
open: minuteFrame ? tick.price : Math.max(tick.open, tick.price),
|
||||
high: minuteFrame ? tick.price : Math.max(tick.high, tick.price),
|
||||
low: minuteFrame ? tick.price : Math.min(tick.low || tick.price, tick.price),
|
||||
close: tick.price,
|
||||
volume: minuteFrame
|
||||
? Math.max(tick.tradeVolume, 0)
|
||||
: Math.max(tick.accumulatedVolume, 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description lightweight-charts X축 라벨을 KST 기준으로 강제 포맷합니다.
|
||||
* @see features/trade/components/chart/StockLineChart.tsx createChart options.timeScale.tickMarkFormatter
|
||||
*/
|
||||
export function formatKstTickMark(time: Time, tickMarkType: TickMarkType) {
|
||||
const date = toDateFromChartTime(time);
|
||||
if (!date) return null;
|
||||
|
||||
if (tickMarkType === 0) return KST_YEAR_FORMATTER.format(date);
|
||||
if (tickMarkType === 1) return KST_MONTH_FORMATTER.format(date);
|
||||
if (tickMarkType === 2) return KST_DATE_FORMATTER.format(date);
|
||||
if (tickMarkType === 4) return KST_TIME_SECONDS_FORMATTER.format(date);
|
||||
return KST_TIME_FORMATTER.format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description crosshair 시간 라벨을 KST로 포맷합니다.
|
||||
* @see features/trade/components/chart/StockLineChart.tsx createChart options.localization.timeFormatter
|
||||
*/
|
||||
export function formatKstCrosshairTime(time: Time) {
|
||||
const date = toDateFromChartTime(time);
|
||||
if (!date) return "";
|
||||
return KST_CROSSHAIR_FORMATTER.format(date);
|
||||
}
|
||||
|
||||
// ─── 포맷터 ───────────────────────────────────────────────
|
||||
|
||||
export function formatPrice(value: number) {
|
||||
return KRW_FORMATTER.format(Math.round(value));
|
||||
}
|
||||
|
||||
export function formatSignedPercent(value: number) {
|
||||
const sign = value > 0 ? "+" : "";
|
||||
return `${sign}${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분봉 타임프레임인지 판별
|
||||
*/
|
||||
export function isMinuteTimeframe(tf: DashboardChartTimeframe) {
|
||||
return tf === "1m" || tf === "30m" || tf === "1h";
|
||||
}
|
||||
|
||||
function normalizeTickTime(value?: string) {
|
||||
if (!value) return null;
|
||||
const normalized = value.trim();
|
||||
return /^\d{6}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function getKstYmd(now = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(now);
|
||||
|
||||
const map = new Map(parts.map((part) => [part.type, part.value]));
|
||||
return `${map.get("year")}${map.get("month")}${map.get("day")}`;
|
||||
}
|
||||
|
||||
function toKstTimestamp(yyyymmdd: string, hhmmss: string) {
|
||||
const y = Number(yyyymmdd.slice(0, 4));
|
||||
const m = Number(yyyymmdd.slice(4, 6));
|
||||
const d = Number(yyyymmdd.slice(6, 8));
|
||||
const hh = Number(hhmmss.slice(0, 2));
|
||||
const mm = Number(hhmmss.slice(2, 4));
|
||||
const ss = Number(hhmmss.slice(4, 6));
|
||||
return Math.floor(Date.UTC(y, m - 1, d, hh - 9, mm, ss) / 1000);
|
||||
}
|
||||
|
||||
function toDateFromChartTime(time: Time) {
|
||||
if (typeof time === "number" && Number.isFinite(time)) {
|
||||
return new Date(time * 1000);
|
||||
}
|
||||
|
||||
if (typeof time === "string") {
|
||||
const parsed = Date.parse(time);
|
||||
return Number.isFinite(parsed) ? new Date(parsed) : null;
|
||||
}
|
||||
|
||||
if (time && typeof time === "object" && "year" in time) {
|
||||
const { year, month, day } = time;
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user