diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 0000000..06f2213 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,6 @@ +{ + "tools": { + "approvalMode": "auto_edit", + "allowed": ["run_shell_command"] + } +} diff --git a/app/api/kis/domestic/orderbook/route.ts b/app/api/kis/domestic/orderbook/route.ts index 104b569..f6f10cf 100644 --- a/app/api/kis/domestic/orderbook/route.ts +++ b/app/api/kis/domestic/orderbook/route.ts @@ -9,6 +9,10 @@ import { hasKisConfig, normalizeTradingEnv, } from "@/lib/kis/config"; +import { + DOMESTIC_KIS_SESSION_OVERRIDE_HEADER, + parseDomesticKisSession, +} from "@/lib/kis/domestic-market-session"; /** * @file app/api/kis/domestic/orderbook/route.ts @@ -38,15 +42,22 @@ export async function GET(request: NextRequest) { } try { - const raw = await getDomesticOrderBook(symbol, credentials); + const sessionOverride = readSessionOverrideFromHeaders(request.headers); + const raw = await getDomesticOrderBook(symbol, credentials, { + sessionOverride, + }); const levels = Array.from({ length: 10 }, (_, i) => { const idx = i + 1; return { - askPrice: readOrderBookNumber(raw, `askp${idx}`), - bidPrice: readOrderBookNumber(raw, `bidp${idx}`), - askSize: readOrderBookNumber(raw, `askp_rsqn${idx}`), - bidSize: readOrderBookNumber(raw, `bidp_rsqn${idx}`), + askPrice: readOrderBookNumber(raw, `askp${idx}`, `ovtm_untp_askp${idx}`), + bidPrice: readOrderBookNumber(raw, `bidp${idx}`, `ovtm_untp_bidp${idx}`), + askSize: readOrderBookNumber( + raw, + `askp_rsqn${idx}`, + `ovtm_untp_askp_rsqn${idx}`, + ), + bidSize: readOrderBookNumber(raw, ...resolveBidSizeKeys(idx)), }; }); @@ -54,8 +65,20 @@ export async function GET(request: NextRequest) { symbol, source: "kis", levels, - totalAskSize: readOrderBookNumber(raw, "total_askp_rsqn"), - totalBidSize: readOrderBookNumber(raw, "total_bidp_rsqn"), + totalAskSize: readOrderBookNumber( + raw, + "total_askp_rsqn", + "ovtm_untp_total_askp_rsqn", + "ovtm_total_askp_rsqn", + ), + totalBidSize: readOrderBookNumber( + raw, + "total_bidp_rsqn", + "ovtm_untp_total_bidp_rsqn", + "ovtm_total_bidp_rsqn", + ), + businessHour: readOrderBookString(raw, "bsop_hour", "ovtm_untp_last_hour"), + hourClassCode: readOrderBookString(raw, "hour_cls_code"), tradingEnv: normalizeTradingEnv(credentials.tradingEnv), fetchedAt: new Date().toISOString(), }; @@ -88,15 +111,19 @@ function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput { }; } +function readSessionOverrideFromHeaders(headers: Headers) { + if (process.env.NODE_ENV === "production") return null; + const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER); + return parseDomesticKisSession(raw); +} + /** * @description 호가 응답 필드를 대소문자 모두 허용해 숫자로 읽습니다. * @see app/api/kis/domestic/orderbook/route.ts GET에서 output/output1 키 차이 방어 로직으로 사용합니다. */ -function readOrderBookNumber(raw: KisDomesticOrderBookOutput, key: string) { +function readOrderBookNumber(raw: KisDomesticOrderBookOutput, ...keys: string[]) { const record = raw as Record; - const direct = record[key]; - const upper = record[key.toUpperCase()]; - const value = direct ?? upper ?? "0"; + const value = resolveOrderBookValue(record, keys) ?? "0"; const normalized = typeof value === "string" ? value.replaceAll(",", "").trim() @@ -104,3 +131,35 @@ function readOrderBookNumber(raw: KisDomesticOrderBookOutput, key: string) { const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : 0; } + +/** + * @description 호가 응답 필드를 문자열로 읽습니다. + * @see app/api/kis/domestic/orderbook/route.ts GET 응답 생성 시 businessHour/hourClassCode 추출 + */ +function readOrderBookString(raw: KisDomesticOrderBookOutput, ...keys: string[]) { + const record = raw as Record; + const value = resolveOrderBookValue(record, keys); + if (value === undefined || value === null) return undefined; + const text = String(value).trim(); + return text.length > 0 ? text : undefined; +} + +function resolveOrderBookValue(record: Record, keys: string[]) { + for (const key of keys) { + const direct = record[key]; + if (direct !== undefined && direct !== null) return direct; + + const upper = record[key.toUpperCase()]; + if (upper !== undefined && upper !== null) return upper; + } + + return undefined; +} + +function resolveBidSizeKeys(index: number) { + if (index === 2) { + return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`, "ovtm_untp_bidp_rsqn"]; + } + + return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`]; +} diff --git a/app/api/kis/domestic/overview/route.ts b/app/api/kis/domestic/overview/route.ts index b658312..0156520 100644 --- a/app/api/kis/domestic/overview/route.ts +++ b/app/api/kis/domestic/overview/route.ts @@ -4,6 +4,10 @@ import type { KisCredentialInput } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { getDomesticOverview } from "@/lib/kis/domestic"; import { NextRequest, NextResponse } from "next/server"; +import { + DOMESTIC_KIS_SESSION_OVERRIDE_HEADER, + parseDomesticKisSession, +} from "@/lib/kis/domestic-market-session"; /** * @file app/api/kis/domestic/overview/route.ts @@ -38,7 +42,13 @@ export async function GET(request: NextRequest) { const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol); try { - const overview = await getDomesticOverview(symbol, fallbackMeta, credentials); + const sessionOverride = readSessionOverrideFromHeaders(request.headers); + const overview = await getDomesticOverview( + symbol, + fallbackMeta, + credentials, + { sessionOverride }, + ); const response: DashboardStockOverviewResponse = { stock: overview.stock, @@ -76,3 +86,9 @@ function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput { tradingEnv, }; } + +function readSessionOverrideFromHeaders(headers: Headers) { + if (process.env.NODE_ENV === "production") return null; + const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER); + return parseDomesticKisSession(raw); +} diff --git a/features/dashboard/apis/kis-stock.api.ts b/features/dashboard/apis/kis-stock.api.ts index 14fcf8d..7d3bb55 100644 --- a/features/dashboard/apis/kis-stock.api.ts +++ b/features/dashboard/apis/kis-stock.api.ts @@ -8,6 +8,11 @@ import type { DashboardStockOverviewResponse, DashboardStockSearchResponse, } from "@/features/dashboard/types/dashboard.types"; +import { + DOMESTIC_KIS_SESSION_OVERRIDE_HEADER, + DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY, + parseDomesticKisSession, +} from "@/lib/kis/domestic-market-session"; /** * 종목 검색 API 호출 @@ -51,11 +56,7 @@ export async function fetchStockOverview( `/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`, { method: "GET", - headers: { - "x-kis-app-key": credentials.appKey, - "x-kis-app-secret": credentials.appSecret, - "x-kis-trading-env": credentials.tradingEnv, - }, + headers: buildKisRequestHeaders(credentials), cache: "no-store", }, ); @@ -87,12 +88,8 @@ export async function fetchStockOrderBook( `/api/kis/domestic/orderbook?symbol=${encodeURIComponent(symbol)}`, { method: "GET", - headers: { - "x-kis-app-key": credentials.appKey, - "x-kis-app-secret": credentials.appSecret, - "x-kis-trading-env": credentials.tradingEnv, - }, - cache: "no-store", // 호가는 실시간성이 중요하므로 항상 최신 데이터 조회 + headers: buildKisRequestHeaders(credentials), + cache: "no-store", signal, }, ); @@ -127,11 +124,7 @@ export async function fetchStockChart( const response = await fetch(`/api/kis/domestic/chart?${query.toString()}`, { method: "GET", - headers: { - "x-kis-app-key": credentials.appKey, - "x-kis-app-secret": credentials.appSecret, - "x-kis-trading-env": credentials.tradingEnv, - }, + headers: buildKisRequestHeaders(credentials), cache: "no-store", }); @@ -159,12 +152,7 @@ export async function fetchOrderCash( ): Promise { const response = await fetch("/api/kis/domestic/order-cash", { method: "POST", - headers: { - "content-type": "application/json", - "x-kis-app-key": credentials.appKey, - "x-kis-app-secret": credentials.appSecret, - "x-kis-trading-env": credentials.tradingEnv, - }, + headers: buildKisRequestHeaders(credentials, { jsonContentType: true }), body: JSON.stringify(request), cache: "no-store", }); @@ -177,3 +165,38 @@ export async function fetchOrderCash( return payload; } + +function buildKisRequestHeaders( + credentials: KisRuntimeCredentials, + options?: { jsonContentType?: boolean }, +) { + const headers: Record = { + "x-kis-app-key": credentials.appKey, + "x-kis-app-secret": credentials.appSecret, + "x-kis-trading-env": credentials.tradingEnv, + }; + + if (options?.jsonContentType) { + headers["content-type"] = "application/json"; + } + + const sessionOverride = readSessionOverrideForDev(); + if (sessionOverride) { + headers[DOMESTIC_KIS_SESSION_OVERRIDE_HEADER] = sessionOverride; + } + + return headers; +} + +function readSessionOverrideForDev() { + if (typeof window === "undefined") return null; + + try { + const raw = window.localStorage.getItem( + DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY, + ); + return parseDomesticKisSession(raw); + } catch { + return null; + } +} \ No newline at end of file diff --git a/features/dashboard/components/DashboardContainer.tsx b/features/dashboard/components/DashboardContainer.tsx index 608a053..8866fc2 100644 --- a/features/dashboard/components/DashboardContainer.tsx +++ b/features/dashboard/components/DashboardContainer.tsx @@ -72,17 +72,16 @@ export function DashboardContainer() { ); // 1. Trade WebSocket (체결 + 호가 통합) - const { latestTick, realtimeCandles, recentTradeTicks } = - useKisTradeWebSocket( - selectedStock?.symbol, - verifiedCredentials, - isKisVerified, - updateRealtimeTradeTick, - { - orderBookSymbol: selectedStock?.symbol, - onOrderBookMessage: handleOrderBookMessage, - }, - ); + const { latestTick, recentTradeTicks } = useKisTradeWebSocket( + selectedStock?.symbol, + verifiedCredentials, + isKisVerified, + updateRealtimeTradeTick, + { + orderBookSymbol: selectedStock?.symbol, + onOrderBookMessage: handleOrderBookMessage, + }, + ); // 2. OrderBook (REST 초기 조회 + WS 실시간 병합) const { orderBook, isLoading: isOrderBookLoading } = useOrderBook( @@ -303,12 +302,9 @@ export function DashboardContainer() {
0 - ? realtimeCandles - : selectedStock.candles - } + candles={selectedStock.candles} credentials={verifiedCredentials} + latestTick={latestTick} />
) : ( diff --git a/features/dashboard/components/chart/StockLineChart.tsx b/features/dashboard/components/chart/StockLineChart.tsx index 18e81ac..c2e3a63 100644 --- a/features/dashboard/components/chart/StockLineChart.tsx +++ b/features/dashboard/components/chart/StockLineChart.tsx @@ -16,24 +16,28 @@ import { fetchStockChart } from "@/features/dashboard/apis/kis-stock.api"; import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store"; import type { DashboardChartTimeframe, + DashboardRealtimeTradeTick, StockCandlePoint, } from "@/features/dashboard/types/dashboard.types"; import { cn } from "@/lib/utils"; import { type ChartBar, - convertCandleToBar, + formatKstCrosshairTime, + formatKstTickMark, formatPrice, formatSignedPercent, isMinuteTimeframe, mergeBars, normalizeCandles, + toRealtimeTickBar, upsertRealtimeBar, } from "./chart-utils"; const UP_COLOR = "#ef4444"; const DOWN_COLOR = "#2563eb"; +const MINUTE_SYNC_INTERVAL_MS = 5000; +const REALTIME_STALE_THRESHOLD_MS = 12000; -// 분봉 드롭다운 옵션 const MINUTE_TIMEFRAMES: Array<{ value: DashboardChartTimeframe; label: string; @@ -43,7 +47,6 @@ const MINUTE_TIMEFRAMES: Array<{ { value: "1h", label: "1시간" }, ]; -// 일봉 이상 개별 버튼 const PERIOD_TIMEFRAMES: Array<{ value: DashboardChartTimeframe; label: string; @@ -52,23 +55,23 @@ const PERIOD_TIMEFRAMES: Array<{ { value: "1w", label: "주" }, ]; -// ChartBar 타입은 chart-utils.ts에서 import - interface StockLineChartProps { symbol?: string; candles: StockCandlePoint[]; credentials?: KisRuntimeCredentials | null; + latestTick?: DashboardRealtimeTradeTick | 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 + * @description TradingView 스타일 캔들 차트를 렌더링하고, timeframe별 KIS 차트 API를 조회합니다. + * @see features/dashboard/apis/kis-stock.api.ts fetchStockChart + * @see lib/kis/domestic.ts getDomesticChart */ export function StockLineChart({ symbol, candles, credentials, + latestTick, }: StockLineChartProps) { const containerRef = useRef(null); const chartRef = useRef(null); @@ -83,11 +86,14 @@ export function StockLineChart({ const [isLoadingMore, setIsLoadingMore] = useState(false); const [isChartReady, setIsChartReady] = useState(false); const lastRealtimeKeyRef = useRef(""); + const lastRealtimeAppliedAtRef = useRef(0); + + // 복수 이벤트에서 중복 로드를 막기 위한 ref 상태 const loadingMoreRef = useRef(false); const loadMoreHandlerRef = useRef<() => Promise>(async () => {}); const initialLoadCompleteRef = useRef(false); - // candles prop을 ref로 관리하여 useEffect 디펜던시에서 제거 (무한 페칭 방지) + // API 오류 시 fallback 용도로 유지 const latestCandlesRef = useRef(candles); useEffect(() => { latestCandlesRef.current = candles; @@ -97,8 +103,10 @@ export function StockLineChart({ 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(); + for (const bar of bars) { if ( !Number.isFinite(bar.time) || @@ -110,23 +118,25 @@ export function StockLineChart({ ) { continue; } + dedup.set(bar.time, bar); } + return [...dedup.values()].sort((a, b) => a.time - b.time); }, [bars]); + /** + * @description lightweight-charts 시리즈 데이터에 안전한 OHLCV 값을 주입합니다. + * @see features/dashboard/components/chart/StockLineChart.tsx renderableBars useMemo + */ 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) => ({ + nextBars.map((bar) => ({ time: bar.time, open: bar.open, high: bar.high, @@ -136,7 +146,7 @@ export function StockLineChart({ ); volumeSeries.setData( - safeBars.map((bar) => ({ + nextBars.map((bar) => ({ time: bar.time, value: Number.isFinite(bar.volume) ? bar.volume : 0, color: @@ -150,14 +160,16 @@ export function StockLineChart({ } }, []); + /** + * @description 좌측 스크롤 시 cursor 기반 과거 캔들을 추가 로드합니다. + * @see lib/kis/domestic.ts getDomesticChart cursor + */ const handleLoadMore = useCallback(async () => { - // 분봉은 당일 데이터만 제공되므로 과거 로딩 불가 - if (isMinuteTimeframe(timeframe)) return; - if (!symbol || !credentials || !nextCursor || loadingMoreRef.current) - return; + if (!symbol || !credentials || !nextCursor || loadingMoreRef.current) return; loadingMoreRef.current = true; setIsLoadingMore(true); + try { const response = await fetchStockChart( symbol, @@ -165,15 +177,16 @@ export function StockLineChart({ credentials, nextCursor, ); - const older = normalizeCandles(response.candles, timeframe); - setBars((prev) => mergeBars(older, prev)); + + const olderBars = normalizeCandles(response.candles, timeframe); + setBars((prev) => mergeBars(olderBars, prev)); setNextCursor(response.hasMore ? response.nextCursor : null); } catch (error) { - const msg = + const message = error instanceof Error ? error.message - : "과거 차트 조회에 실패했습니다."; - toast.error(msg); + : "과거 차트 데이터를 불러오지 못했습니다."; + toast.error(message); } finally { loadingMoreRef.current = false; setIsLoadingMore(false); @@ -184,16 +197,18 @@ export function StockLineChart({ loadMoreHandlerRef.current = handleLoadMore; }, [handleLoadMore]); + useEffect(() => { + lastRealtimeKeyRef.current = ""; + lastRealtimeAppliedAtRef.current = 0; + }, [symbol, timeframe]); + 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, + width: Math.max(container.clientWidth, 320), + height: Math.max(container.clientHeight, 340), layout: { background: { type: ColorType.Solid, color: "#ffffff" }, textColor: "#475569", @@ -201,6 +216,7 @@ export function StockLineChart({ }, localization: { locale: "ko-KR", + timeFormatter: formatKstCrosshairTime, }, rightPriceScale: { borderColor: "#e2e8f0", @@ -222,6 +238,7 @@ export function StockLineChart({ timeVisible: true, secondsVisible: false, rightOffset: 2, + tickMarkFormatter: formatKstTickMark, }, handleScroll: { mouseWheel: true, @@ -260,19 +277,15 @@ export function StockLineChart({ borderVisible: false, }); - // 스크롤 디바운스 타이머 - let scrollTimeout: NodeJS.Timeout | null = null; - + let scrollTimeout: number | undefined; chart.timeScale().subscribeVisibleLogicalRangeChange((range) => { - if (!range) return; + if (!range || !initialLoadCompleteRef.current) return; + if (range.from >= 10) return; - // 분봉은 당일 데이터만 제공되므로 무한 스크롤 비활성화 - if (range.from < 10 && initialLoadCompleteRef.current) { - if (scrollTimeout) clearTimeout(scrollTimeout); - scrollTimeout = setTimeout(() => { - void loadMoreHandlerRef.current(); - }, 300); - } + if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout); + scrollTimeout = window.setTimeout(() => { + void loadMoreHandlerRef.current(); + }, 250); }); chartRef.current = chart; @@ -281,20 +294,22 @@ export function StockLineChart({ setIsChartReady(true); const resizeObserver = new ResizeObserver(() => { - const nextWidth = Math.max(container.clientWidth, 320); - const nextHeight = Math.max(container.clientHeight, 340); - chart.resize(nextWidth, nextHeight); + chart.resize( + Math.max(container.clientWidth, 320), + Math.max(container.clientHeight, 340), + ); }); 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); + 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(); @@ -307,6 +322,8 @@ export function StockLineChart({ useEffect(() => { if (symbol && credentials) return; + + // 인증 전/종목 미선택 상태는 overview 캔들로 fallback setBars(normalizeCandles(candles, "1d")); setNextCursor(null); }, [candles, credentials, symbol]); @@ -314,33 +331,56 @@ export function StockLineChart({ useEffect(() => { if (!symbol || !credentials) return; - // 초기 로딩 보호 플래그 초기화 (타임프레임/종목 변경 시) initialLoadCompleteRef.current = false; - let disposed = false; + const load = async () => { setIsLoading(true); + try { - const response = await fetchStockChart(symbol, timeframe, credentials); + const firstPage = await fetchStockChart(symbol, timeframe, credentials); if (disposed) return; - const normalized = normalizeCandles(response.candles, timeframe); - setBars(normalized); - setNextCursor(response.hasMore ? response.nextCursor : null); + let mergedBars = normalizeCandles(firstPage.candles, timeframe); + let resolvedNextCursor = firstPage.hasMore ? firstPage.nextCursor : null; - // 초기 로딩 완료 후 500ms 지연 후 무한 스크롤 활성화 - // (fitContent 후 range가 0이 되어 즉시 트리거되는 것 방지) - setTimeout(() => { - if (!disposed) { - initialLoadCompleteRef.current = true; + // 분봉은 기본 2페이지를 붙여서 "당일만 보이는" 느낌을 줄입니다. + if ( + isMinuteTimeframe(timeframe) && + firstPage.hasMore && + firstPage.nextCursor + ) { + try { + const secondPage = await fetchStockChart( + symbol, + timeframe, + credentials, + firstPage.nextCursor, + ); + + const olderBars = normalizeCandles(secondPage.candles, timeframe); + mergedBars = mergeBars(olderBars, mergedBars); + resolvedNextCursor = secondPage.hasMore ? secondPage.nextCursor : null; + } catch { + // 2페이지 실패는 치명적이지 않으므로 1페이지 데이터는 유지합니다. } - }, 500); + } + + setBars(mergedBars); + setNextCursor(resolvedNextCursor); + + window.setTimeout(() => { + if (!disposed) initialLoadCompleteRef.current = true; + }, 350); } catch (error) { if (disposed) return; + const message = - error instanceof Error ? error.message : "차트 조회에 실패했습니다."; + error instanceof Error + ? error.message + : "차트 조회 중 오류가 발생했습니다."; toast.error(message); - // 에러 발생 시 fallback으로 props로 전달된 candles 사용 + setBars(normalizeCandles(latestCandlesRef.current, timeframe)); setNextCursor(null); } finally { @@ -349,6 +389,7 @@ export function StockLineChart({ }; void load(); + return () => { disposed = true; }; @@ -356,37 +397,84 @@ export function StockLineChart({ useEffect(() => { if (!isChartReady) return; - setSeriesData(renderableBars); - // 초기 로딩 시에만 fitContent 수행 + setSeriesData(renderableBars); if (!initialLoadCompleteRef.current && renderableBars.length > 0) { chartRef.current?.timeScale().fitContent(); } }, [isChartReady, renderableBars, setSeriesData]); - const latestRealtime = useMemo(() => candles.at(-1), [candles]); + /** + * @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다. + * @see features/dashboard/hooks/useKisTradeWebSocket.ts latestTick + * @see features/dashboard/components/chart/chart-utils.ts toRealtimeTickBar + */ useEffect(() => { - if (!latestRealtime || bars.length === 0) return; - if (timeframe === "1w" && !latestRealtime.timestamp && !latestRealtime.time) - return; + if (!latestTick) return; + if (bars.length === 0) return; - const key = `${latestRealtime.time}-${latestRealtime.price}-${latestRealtime.volume ?? 0}`; - if (lastRealtimeKeyRef.current === key) return; - lastRealtimeKeyRef.current = key; + const dedupeKey = `${timeframe}:${latestTick.tickTime}:${latestTick.price}:${latestTick.tradeVolume}`; + if (lastRealtimeKeyRef.current === dedupeKey) return; - const nextBar = convertCandleToBar(latestRealtime, timeframe); - if (!nextBar) return; + const realtimeBar = toRealtimeTickBar(latestTick, timeframe); + if (!realtimeBar) return; - setBars((prev) => upsertRealtimeBar(prev, nextBar)); - }, [bars.length, candles, latestRealtime, timeframe]); + lastRealtimeKeyRef.current = dedupeKey; + lastRealtimeAppliedAtRef.current = Date.now(); + setBars((prev) => upsertRealtimeBar(prev, realtimeBar)); + }, [bars.length, latestTick, timeframe]); + + /** + * @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다. + * @see features/dashboard/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) + if (isLoading && bars.length === 0) { return "차트 데이터를 불러오는 중입니다."; - if (bars.length === 0) return "차트 데이터가 없습니다."; - if (renderableBars.length === 0) - return "차트 데이터 형식이 올바르지 않아 렌더링할 수 없습니다."; + } + if (bars.length === 0) { + return "차트 데이터가 없습니다."; + } + if (renderableBars.length === 0) { + return "차트 데이터 형식이 올바르지 않습니다."; + } return null; })(); @@ -395,24 +483,24 @@ export function StockLineChart({ {/* ========== CHART TOOLBAR ========== */}
- {/* 분봉 드롭다운 */}
+ {isMinuteDropdownOpen && (
{MINUTE_TIMEFRAMES.map((item) => ( @@ -436,7 +524,6 @@ export function StockLineChart({ )}
- {/* 일/주 버튼 */} {PERIOD_TIMEFRAMES.map((item) => (
+
- O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)}{" "} - L {formatPrice(latest?.low ?? 0)} C{" "} + O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)} L{" "} + {formatPrice(latest?.low ?? 0)} C{" "} = 0 ? "text-red-600" : "text-blue-600")}> - {formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)} - ) + {formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)})
{/* ========== CHART BODY ========== */}
- {/* 차트 캔버스 컨테이너 - 항상 렌더링 */}
- {/* 상태 메시지 오버레이 */} {statusMessage && (
{statusMessage} diff --git a/features/dashboard/components/chart/chart-utils.ts b/features/dashboard/components/chart/chart-utils.ts index 5d6b509..5766ba0 100644 --- a/features/dashboard/components/chart/chart-utils.ts +++ b/features/dashboard/components/chart/chart-utils.ts @@ -3,13 +3,53 @@ * @description StockLineChart에서 사용하는 유틸리티 함수 모음 */ -import type { UTCTimestamp } from "lightweight-charts"; +import type { + TickMarkType, + Time, + UTCTimestamp, +} from "lightweight-charts"; import type { DashboardChartTimeframe, + DashboardRealtimeTradeTick, StockCandlePoint, } from "@/features/dashboard/types/dashboard.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, +}); // ─── 타입 ────────────────────────────────────────────────── @@ -186,6 +226,63 @@ export function upsertRealtimeBar( ]; } +/** + * @description 실시간 체결 틱을 차트용 ChartBar로 변환합니다. (KST 날짜 + tickTime 기준) + * @see features/dashboard/hooks/useKisTradeWebSocket.ts latestTick + * @see features/dashboard/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/dashboard/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/dashboard/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) { @@ -203,3 +300,49 @@ export function formatSignedPercent(value: number) { 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; +} diff --git a/features/dashboard/components/details/StockOverviewCard.tsx b/features/dashboard/components/details/StockOverviewCard.tsx index 3de1819..1a09012 100644 --- a/features/dashboard/components/details/StockOverviewCard.tsx +++ b/features/dashboard/components/details/StockOverviewCard.tsx @@ -1,5 +1,4 @@ import { Activity, ShieldCheck } from "lucide-react"; -import { cn } from "@/lib/utils"; import { Card, CardContent, diff --git a/features/dashboard/hooks/useKisTradeWebSocket.ts b/features/dashboard/hooks/useKisTradeWebSocket.ts index 7be3b78..17a1eb2 100644 --- a/features/dashboard/hooks/useKisTradeWebSocket.ts +++ b/features/dashboard/hooks/useKisTradeWebSocket.ts @@ -6,53 +6,50 @@ import { import type { DashboardRealtimeTradeTick, DashboardStockOrderBookResponse, - StockCandlePoint, } from "@/features/dashboard/types/dashboard.types"; import { - appendRealtimeTick, buildKisRealtimeMessage, - formatRealtimeTickTime, parseKisRealtimeOrderbook, parseKisRealtimeTickBatch, - toTickOrderValue, } from "@/features/dashboard/utils/kis-realtime.utils"; +import { + DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY, + resolveDomesticKisSession, + shouldUseAfterHoursSinglePriceTr, + shouldUseExpectedExecutionTr, + type DomesticKisSession, +} from "@/lib/kis/domestic-market-session"; -// ─── TR ID 상수 ───────────────────────────────────────── -const TRADE_TR_ID = "H0STCNT0"; // 체결 (실전/모의 공통) -const TRADE_TR_ID_OVERTIME = "H0STOUP0"; // 시간외 단일가 -const ORDERBOOK_TR_ID = "H0STASP0"; // 호가 (정규장) -const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0"; // 호가 (시간외) - +const TRADE_TR_ID = "H0STCNT0"; +const TRADE_TR_ID_EXPECTED = "H0STANC0"; +const TRADE_TR_ID_OVERTIME = "H0STOUP0"; +const ORDERBOOK_TR_ID = "H0STASP0"; +const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0"; const MAX_TRADE_TICKS = 10; -// ─── 시간대별 TR ID 선택 ──────────────────────────────── - -function isOvertimeHours() { - const now = new Date(); - const t = now.getHours() * 100 + now.getMinutes(); - return t >= 1600 && t < 1800; -} - -function resolveTradeTrId(env: KisRuntimeCredentials["tradingEnv"]) { +function resolveTradeTrId( + env: KisRuntimeCredentials["tradingEnv"], + session: DomesticKisSession, +) { if (env === "mock") return TRADE_TR_ID; - return isOvertimeHours() ? TRADE_TR_ID_OVERTIME : TRADE_TR_ID; + if (shouldUseAfterHoursSinglePriceTr(session)) return TRADE_TR_ID_OVERTIME; + if (shouldUseExpectedExecutionTr(session)) return TRADE_TR_ID_EXPECTED; + return TRADE_TR_ID; } -function resolveOrderBookTrId() { - return isOvertimeHours() ? ORDERBOOK_TR_ID_OVERTIME : ORDERBOOK_TR_ID; +function resolveOrderBookTrId( + env: KisRuntimeCredentials["tradingEnv"], + session: DomesticKisSession, +) { + if (env === "mock") return ORDERBOOK_TR_ID; + if (shouldUseAfterHoursSinglePriceTr(session)) return ORDERBOOK_TR_ID_OVERTIME; + return ORDERBOOK_TR_ID; } -// ─── 메인 훅 ──────────────────────────────────────────── - /** - * 통합 실시간 웹소켓 훅 — 체결(H0STCNT0) + 호가(H0STASP0)를 단일 WS로 수신합니다. - * - * @param symbol 종목코드 - * @param credentials KIS 인증 정보 - * @param isVerified 인증 완료 여부 - * @param onTick 체결 콜백 (StockHeader 갱신용) - * @param options.orderBookSymbol 호가 구독 종목코드 - * @param options.onOrderBookMessage 호가 수신 콜백 + * @description KIS 실시간 체결/호가를 단일 WebSocket으로 구독합니다. + * @see features/dashboard/components/DashboardContainer.tsx useKisTradeWebSocket 호출 + * @see lib/kis/domestic-market-session.ts 장 세션 계산 및 테스트용 override */ export function useKisTradeWebSocket( symbol: string | undefined, @@ -66,45 +63,54 @@ export function useKisTradeWebSocket( ) { const [latestTick, setLatestTick] = useState(null); - const [realtimeCandles, setRealtimeCandles] = useState( - [], - ); const [recentTradeTicks, setRecentTradeTicks] = useState< DashboardRealtimeTradeTick[] >([]); const [isConnected, setIsConnected] = useState(false); const [error, setError] = useState(null); const [lastTickAt, setLastTickAt] = useState(null); + const [marketSession, setMarketSession] = useState(() => + resolveSessionInClient(), + ); const socketRef = useRef(null); const approvalKeyRef = useRef(null); - const lastTickOrderRef = useRef(-1); const seenTickRef = useRef>(new Set()); - const trId = credentials ? resolveTradeTrId(credentials.tradingEnv) : null; const obSymbol = options?.orderBookSymbol; const onOrderBookMsg = options?.onOrderBookMessage; - const obTrId = obSymbol ? resolveOrderBookTrId() : null; + const realtimeTrId = credentials + ? resolveTradeTrId(credentials.tradingEnv, marketSession) + : TRADE_TR_ID; - // 8초간 데이터 없을 시 안내 메시지 + // KST 장 세션을 주기적으로 재평가합니다. + useEffect(() => { + const timerId = window.setInterval(() => { + const nextSession = resolveSessionInClient(); + setMarketSession((prev) => (prev === nextSession ? prev : nextSession)); + }, 30_000); + + return () => window.clearInterval(timerId); + }, []); + + // 연결은 되었는데 체결이 오래 안 들어오는 경우 안내합니다. useEffect(() => { if (!isConnected || lastTickAt) return; const timer = window.setTimeout(() => { setError( - "실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요.", + "실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간) 여부를 확인해 주세요.", ); }, 8000); return () => window.clearTimeout(timer); }, [isConnected, lastTickAt]); - // ─── 웹소켓 연결 ───────────────────────────────────── useEffect(() => { setLatestTick(null); - setRealtimeCandles([]); setRecentTradeTicks([]); setError(null); + setLastTickAt(null); seenTickRef.current.clear(); if (!symbol || !isVerified || !credentials) { @@ -117,7 +123,11 @@ export function useKisTradeWebSocket( let disposed = false; let socket: WebSocket | null = null; - const currentTrId = resolveTradeTrId(credentials.tradingEnv); + + const tradeTrId = resolveTradeTrId(credentials.tradingEnv, marketSession); + const orderBookTrId = obSymbol + ? resolveOrderBookTrId(credentials.tradingEnv, marketSession) + : null; const connect = async () => { try { @@ -128,18 +138,20 @@ export function useKisTradeWebSocket( .getState() .getOrFetchApprovalKey(); - if (!approvalKey) throw new Error("웹소켓 승인키 발급에 실패했습니다."); - if (disposed) return; + if (!approvalKey) { + throw new Error("웹소켓 승인키 발급에 실패했습니다."); + } + if (disposed) return; approvalKeyRef.current = approvalKey; const wsBase = process.env.NEXT_PUBLIC_KIS_WS_URL || "ws://ops.koreainvestment.com:21000"; - socket = new WebSocket(`${wsBase}/tryitout/${currentTrId}`); + + socket = new WebSocket(`${wsBase}/tryitout/${tradeTrId}`); socketRef.current = socket; - // ── onopen: 체결 + 호가 구독 ── socket.onopen = () => { if (disposed || !approvalKeyRef.current) return; @@ -148,19 +160,19 @@ export function useKisTradeWebSocket( buildKisRealtimeMessage( approvalKeyRef.current, symbol, - currentTrId, + tradeTrId, "1", ), ), ); - if (obSymbol && obTrId) { + if (obSymbol && orderBookTrId) { socket?.send( JSON.stringify( buildKisRealtimeMessage( approvalKeyRef.current, obSymbol, - obTrId, + orderBookTrId, "1", ), ), @@ -170,53 +182,35 @@ export function useKisTradeWebSocket( setIsConnected(true); }; - // ── onmessage: TR ID 기반 분기 ── socket.onmessage = (event) => { if (disposed || typeof event.data !== "string") return; - // 호가 메시지 확인 if (obSymbol && onOrderBookMsg) { - const ob = parseKisRealtimeOrderbook(event.data, obSymbol); - if (ob) { - if (credentials) ob.tradingEnv = credentials.tradingEnv; - onOrderBookMsg(ob); + const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol); + if (orderBook) { + orderBook.tradingEnv = credentials.tradingEnv; + onOrderBookMsg(orderBook); return; } } - // 체결 메시지 파싱 const ticks = parseKisRealtimeTickBatch(event.data, symbol); if (ticks.length === 0) return; - // 중복 제거 (TradeTape용) - const meaningful = ticks.filter((t) => t.tradeVolume > 0); - const deduped = meaningful.filter((t) => { - const key = `${t.tickTime}-${t.price}-${t.tradeVolume}`; + const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0); + const dedupedTicks = meaningfulTicks.filter((tick) => { + const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`; if (seenTickRef.current.has(key)) return false; seenTickRef.current.add(key); return true; }); - // 최신 틱 → Header const latest = ticks[ticks.length - 1]; setLatestTick(latest); - // 캔들 → Chart - const order = toTickOrderValue(latest.tickTime); - if (order > 0 && lastTickOrderRef.current <= order) { - lastTickOrderRef.current = order; - setRealtimeCandles((prev) => - appendRealtimeTick(prev, { - time: formatRealtimeTickTime(latest.tickTime), - price: latest.price, - }), - ); - } - - // 체결 테이프 - if (deduped.length > 0) { + if (dedupedTicks.length > 0) { setRecentTradeTicks((prev) => - [...deduped.reverse(), ...prev].slice(0, MAX_TRADE_TICKS), + [...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS), ); } @@ -228,11 +222,13 @@ export function useKisTradeWebSocket( socket.onerror = () => { if (!disposed) setIsConnected(false); }; + socket.onclose = () => { if (!disposed) setIsConnected(false); }; } catch (err) { if (disposed) return; + setError( err instanceof Error ? err.message @@ -245,7 +241,6 @@ export function useKisTradeWebSocket( void connect(); const seenRef = seenTickRef.current; - // ── cleanup: 구독 해제 ── return () => { disposed = true; setIsConnected(false); @@ -253,13 +248,14 @@ export function useKisTradeWebSocket( const key = approvalKeyRef.current; if (socket?.readyState === WebSocket.OPEN && key) { socket.send( - JSON.stringify( - buildKisRealtimeMessage(key, symbol, currentTrId, "2"), - ), + JSON.stringify(buildKisRealtimeMessage(key, symbol, tradeTrId, "2")), ); - if (obSymbol && obTrId) { + + if (obSymbol && orderBookTrId) { socket.send( - JSON.stringify(buildKisRealtimeMessage(key, obSymbol, obTrId, "2")), + JSON.stringify( + buildKisRealtimeMessage(key, obSymbol, orderBookTrId, "2"), + ), ); } } @@ -270,22 +266,36 @@ export function useKisTradeWebSocket( seenRef.clear(); }; }, [ - isVerified, symbol, + isVerified, credentials, + marketSession, onTick, obSymbol, - obTrId, onOrderBookMsg, ]); return { latestTick, - realtimeCandles, recentTradeTicks, isConnected, error, lastTickAt, - realtimeTrId: trId ?? TRADE_TR_ID, + realtimeTrId, }; } + +function resolveSessionInClient() { + if (typeof window === "undefined") { + return resolveDomesticKisSession(); + } + + try { + const override = window.localStorage.getItem( + DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY, + ); + return resolveDomesticKisSession(override); + } catch { + return resolveDomesticKisSession(); + } +} diff --git a/features/dashboard/hooks/useStockOverview.ts b/features/dashboard/hooks/useStockOverview.ts index 38b8582..157c853 100644 --- a/features/dashboard/hooks/useStockOverview.ts +++ b/features/dashboard/hooks/useStockOverview.ts @@ -57,47 +57,33 @@ export function useStockOverview() { [], ); - // 실시간 체결 데이터 수신 시 헤더/차트 기준 가격을 갱신합니다. + /** + * 실시간 체결 수신 시 헤더/주요 시세 상태만 갱신합니다. + * 차트 캔들은 StockLineChart 내부 API 응답을 기준으로 유지합니다. + * @see features/dashboard/components/DashboardContainer.tsx useKisTradeWebSocket onTick 전달 + * @see features/dashboard/components/chart/StockLineChart.tsx 차트 데이터 fetchStockChart 기준 렌더링 + */ const updateRealtimeTradeTick = useCallback( (tick: DashboardRealtimeTradeTick) => { setSelectedStock((prev) => { if (!prev) return prev; - const { price, accumulatedVolume, change, changeRate, tickTime } = tick; - const pointTime = - tickTime && tickTime.length === 6 - ? `${tickTime.slice(0, 2)}:${tickTime.slice(2, 4)}` - : "실시간"; + const { price, accumulatedVolume, change, changeRate } = tick; const nextChange = change; const nextChangeRate = Number.isFinite(changeRate) ? changeRate : prev.prevClose > 0 ? (nextChange / prev.prevClose) * 100 : prev.changeRate; - const nextHigh = prev.high > 0 ? Math.max(prev.high, price) : price; - const nextLow = prev.low > 0 ? Math.min(prev.low, price) : price; - const nextCandles = - prev.candles.length > 0 && - prev.candles[prev.candles.length - 1]?.time === pointTime - ? [ - ...prev.candles.slice(0, -1), - { - ...prev.candles[prev.candles.length - 1], - time: pointTime, - price, - }, - ] - : [...prev.candles, { time: pointTime, price }].slice(-80); return { ...prev, currentPrice: price, change: nextChange, changeRate: nextChangeRate, - high: nextHigh, - low: nextLow, + high: prev.high > 0 ? Math.max(prev.high, price) : price, + low: prev.low > 0 ? Math.min(prev.low, price) : price, volume: accumulatedVolume > 0 ? accumulatedVolume : prev.volume, - candles: nextCandles, }; }); }, diff --git a/features/dashboard/utils/kis-realtime.utils.ts b/features/dashboard/utils/kis-realtime.utils.ts index 6854e6b..659f214 100644 --- a/features/dashboard/utils/kis-realtime.utils.ts +++ b/features/dashboard/utils/kis-realtime.utils.ts @@ -1,10 +1,15 @@ import type { DashboardRealtimeTradeTick, DashboardStockOrderBookResponse, - StockCandlePoint, } from "@/features/dashboard/types/dashboard.types"; const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]); +const ALLOWED_REALTIME_TRADE_TR_IDS = new Set([ + "H0STCNT0", + "H0STANC0", + "H0STOUP0", + "H0STOAC0", +]); const TICK_FIELD_INDEX = { symbol: 0, @@ -27,7 +32,7 @@ const TICK_FIELD_INDEX = { } as const; /** - * KIS 실시간 구독/해제 웹소켓 메시지를 생성합니다. + * KIS ?ㅼ떆媛?援щ룆/?댁젣 ?뱀냼耳?硫붿떆吏€瑜??앹꽦?⑸땲?? */ export function buildKisRealtimeMessage( approvalKey: string, @@ -52,9 +57,9 @@ export function buildKisRealtimeMessage( } /** - * 실시간 체결 스트림(raw)을 배열 단위로 파싱합니다. - * - 배치 전송(복수 틱)일 때도 모든 틱을 추출 - * - 심볼 불일치/가격 0 이하 데이터는 제외 + * ?ㅼ떆媛?泥닿껐 ?ㅽ듃由?raw)??諛곗뿴 ?⑥쐞濡??뚯떛?⑸땲?? + * - 諛곗튂 ?꾩넚(蹂듭닔 ?????뚮룄 紐⑤뱺 ?깆쓣 異붿텧 + * - ?щ낵 遺덉씪移?媛€寃?0 ?댄븯 ?곗씠?곕뒗 ?쒖쇅 */ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) { if (!/^([01])\|/.test(raw)) return [] as DashboardRealtimeTradeTick[]; @@ -62,10 +67,9 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) { const parts = raw.split("|"); if (parts.length < 4) return [] as DashboardRealtimeTradeTick[]; - // TR ID Check: Allow H0STCNT0 (Real/Mock) or H0STOUP0 (Overtime) + // TR ID check: regular tick / expected tick / after-hours tick. const receivedTrId = parts[1]; - if (receivedTrId !== "H0STCNT0" && receivedTrId !== "H0STOUP0") { - // console.warn("[KisRealtime] Unknown TR ID for Trade Tick:", receivedTrId); + if (!ALLOWED_REALTIME_TRADE_TR_IDS.has(receivedTrId)) { return [] as DashboardRealtimeTradeTick[]; } @@ -147,32 +151,8 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) { return ticks; } -export function formatRealtimeTickTime(hhmmss?: string) { - if (!hhmmss || hhmmss.length !== 6) return "실시간"; - return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`; -} - -export function appendRealtimeTick( - prev: StockCandlePoint[], - next: StockCandlePoint, -) { - if (prev.length === 0) return [next]; - - const last = prev[prev.length - 1]; - if (last.time === next.time) { - return [...prev.slice(0, -1), next]; - } - - return [...prev, next].slice(-80); -} - -export function toTickOrderValue(hhmmss?: string) { - if (!hhmmss || !/^\d{6}$/.test(hhmmss)) return -1; - return Number(hhmmss); -} - /** - * KIS 실시간 호가(H0STASP0/H0UNASP0/H0STOAA0)를 OrderBook 구조로 파싱합니다. + * KIS ?ㅼ떆媛??멸?(H0STASP0/H0UNASP0/H0STOAA0)瑜?OrderBook 援ъ“濡??뚯떛?⑸땲?? */ export function parseKisRealtimeOrderbook( raw: string, @@ -244,8 +224,8 @@ export function parseKisRealtimeOrderbook( } /** - * @description 국내 종목코드 비교를 위해 접두 문자를 제거하고 6자리 코드로 정규화합니다. - * @see features/dashboard/utils/kis-realtime.utils.ts parseKisRealtimeOrderbook 종목 매칭 비교 + * @description 援?궡 醫낅ぉ肄붾뱶 鍮꾧탳瑜??꾪빐 ?묐몢 臾몄옄瑜??쒓굅?섍퀬 6?먮━ 肄붾뱶濡??뺢퇋?뷀빀?덈떎. + * @see features/dashboard/utils/kis-realtime.utils.ts parseKisRealtimeOrderbook 醫낅ぉ 留ㅼ묶 鍮꾧탳 */ function normalizeDomesticSymbol(value: string) { const trimmed = value.trim(); diff --git a/lib/kis/domestic-market-session.ts b/lib/kis/domestic-market-session.ts new file mode 100644 index 0000000..d81dd31 --- /dev/null +++ b/lib/kis/domestic-market-session.ts @@ -0,0 +1,176 @@ +/** + * @file lib/kis/domestic-market-session.ts + * @description KRX market-session helpers based on KST (Asia/Seoul) + */ + +export type DomesticKisSession = + | "openAuction" + | "regular" + | "closeAuction" + | "afterCloseFixedPrice" + | "afterHoursSinglePrice" + | "closed"; + +export const DOMESTIC_KIS_SESSION_OVERRIDE_HEADER = "x-kis-session-override"; +export const DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY = + "KIS_SESSION_OVERRIDE"; + +const OPEN_AUCTION_START_MINUTES = 8 * 60 + 30; // 08:30 +const OPEN_AUCTION_END_MINUTES = 9 * 60; // 09:00 +const REGULAR_START_MINUTES = 9 * 60; // 09:00 +const REGULAR_END_MINUTES = 15 * 60 + 20; // 15:20 +const CLOSE_AUCTION_START_MINUTES = 15 * 60 + 20; // 15:20 +const CLOSE_AUCTION_END_MINUTES = 15 * 60 + 30; // 15:30 +const AFTER_CLOSE_FIXED_START_MINUTES = 15 * 60 + 40; // 15:40 +const AFTER_CLOSE_FIXED_END_MINUTES = 16 * 60; // 16:00 +const AFTER_HOURS_SINGLE_START_MINUTES = 16 * 60; // 16:00 +const AFTER_HOURS_SINGLE_END_MINUTES = 18 * 60; // 18:00 + +/** + * @description Converts external string to strict session enum. + * @see lib/kis/domestic.ts getDomesticOrderBook + * @see features/dashboard/hooks/useKisTradeWebSocket.ts resolveSessionInClient + */ +export function parseDomesticKisSession(value?: string | null) { + if (!value) return null; + + const normalized = value.trim(); + if (!normalized) return null; + + const allowed: DomesticKisSession[] = [ + "openAuction", + "regular", + "closeAuction", + "afterCloseFixedPrice", + "afterHoursSinglePrice", + "closed", + ]; + + return allowed.includes(normalized as DomesticKisSession) + ? (normalized as DomesticKisSession) + : null; +} + +/** + * @description Returns current session in KST. + * @see features/dashboard/hooks/useKisTradeWebSocket.ts WebSocket TR switching + * @see lib/kis/domestic.ts REST orderbook source switching + */ +export function getDomesticKisSessionInKst(now = new Date()): DomesticKisSession { + const { weekday, totalMinutes } = toKstWeekdayAndMinutes(now); + + if (weekday === "Sat" || weekday === "Sun") { + return "closed"; + } + + if ( + totalMinutes >= OPEN_AUCTION_START_MINUTES && + totalMinutes < OPEN_AUCTION_END_MINUTES + ) { + return "openAuction"; + } + + if ( + totalMinutes >= REGULAR_START_MINUTES && + totalMinutes < REGULAR_END_MINUTES + ) { + return "regular"; + } + + if ( + totalMinutes >= CLOSE_AUCTION_START_MINUTES && + totalMinutes < CLOSE_AUCTION_END_MINUTES + ) { + return "closeAuction"; + } + + if ( + totalMinutes >= AFTER_CLOSE_FIXED_START_MINUTES && + totalMinutes < AFTER_CLOSE_FIXED_END_MINUTES + ) { + return "afterCloseFixedPrice"; + } + + if ( + totalMinutes >= AFTER_HOURS_SINGLE_START_MINUTES && + totalMinutes < AFTER_HOURS_SINGLE_END_MINUTES + ) { + return "afterHoursSinglePrice"; + } + + return "closed"; +} + +/** + * @description If override is valid, use it. Otherwise use real KST time. + * @see app/api/kis/domestic/orderbook/route.ts session override header + * @see features/dashboard/hooks/useKisTradeWebSocket.ts localStorage override + */ +export function resolveDomesticKisSession( + override?: string | null, + now = new Date(), +) { + return parseDomesticKisSession(override) ?? getDomesticKisSessionInKst(now); +} + +/** + * @description Maps detailed KIS session to dashboard phase. + * @see lib/kis/domestic.ts getDomesticOverview + */ +export function mapDomesticKisSessionToMarketPhase( + session: DomesticKisSession, +): "regular" | "afterHours" { + if ( + session === "regular" || + session === "openAuction" || + session === "closeAuction" + ) { + return "regular"; + } + + return "afterHours"; +} + +/** + * @description Whether orderbook should use overtime REST API. + * @see lib/kis/domestic.ts getDomesticOrderBook + */ +export function shouldUseOvertimeOrderBookApi(session: DomesticKisSession) { + return ( + session === "afterCloseFixedPrice" || session === "afterHoursSinglePrice" + ); +} + +/** + * @description Whether trade tick should use expected-execution TR. + * @see features/dashboard/hooks/useKisTradeWebSocket.ts resolveTradeTrId + */ +export function shouldUseExpectedExecutionTr(session: DomesticKisSession) { + return session === "openAuction" || session === "closeAuction"; +} + +/** + * @description Whether trade tick/orderbook should use after-hours single-price TR. + * @see features/dashboard/hooks/useKisTradeWebSocket.ts resolveTradeTrId + */ +export function shouldUseAfterHoursSinglePriceTr(session: DomesticKisSession) { + return session === "afterHoursSinglePrice"; +} + +function toKstWeekdayAndMinutes(now: Date) { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "Asia/Seoul", + weekday: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).formatToParts(now); + + const partMap = new Map(parts.map((part) => [part.type, part.value])); + const weekday = partMap.get("weekday") ?? "Sun"; + const hour = Number(partMap.get("hour") ?? "0"); + const minute = Number(partMap.get("minute") ?? "0"); + const totalMinutes = hour * 60 + minute; + + return { weekday, totalMinutes }; +} diff --git a/lib/kis/domestic.ts b/lib/kis/domestic.ts index 3d77f70..b860577 100644 --- a/lib/kis/domestic.ts +++ b/lib/kis/domestic.ts @@ -5,6 +5,11 @@ import type { } from "@/features/dashboard/types/dashboard.types"; import type { KisCredentialInput } from "@/lib/kis/config"; import { kisGet } from "@/lib/kis/client"; +import { + mapDomesticKisSessionToMarketPhase, + resolveDomesticKisSession, + shouldUseOvertimeOrderBookApi, +} from "@/lib/kis/domestic-market-session"; /** * @file lib/kis/domestic.ts @@ -129,6 +134,10 @@ interface DomesticOverviewResult { marketPhase: DomesticMarketPhase; } +interface DomesticSessionAwareOptions { + sessionOverride?: string | null; +} + /** * 국내주식 현재가 조회 * @param symbol 6자리 종목코드 @@ -238,10 +247,18 @@ export async function getDomesticOvertimePrice( export async function getDomesticOrderBook( symbol: string, credentials?: KisCredentialInput, + options?: DomesticSessionAwareOptions, ) { + const session = resolveDomesticKisSession(options?.sessionOverride); + const useOvertimeApi = shouldUseOvertimeOrderBookApi(session); + const apiPath = useOvertimeApi + ? "/uapi/domestic-stock/v1/quotations/inquire-overtime-asking-price" + : "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn"; + const trId = useOvertimeApi ? "FHPST02300400" : "FHKST01010200"; + const response = await kisGet( - "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", - "FHKST01010200", + apiPath, + trId, { FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(), FID_INPUT_ISCD: symbol, @@ -271,8 +288,12 @@ export async function getDomesticOverview( symbol: string, fallbackMeta?: DashboardStockFallbackMeta, credentials?: KisCredentialInput, + options?: DomesticSessionAwareOptions, ): Promise { - const marketPhase = getDomesticMarketPhaseInKst(); + const marketPhase = getDomesticMarketPhaseInKst( + new Date(), + options?.sessionOverride, + ); const emptyQuote: KisDomesticQuoteOutput = {}; const emptyDaily: KisDomesticDailyPriceOutput[] = []; const emptyCcnl: KisDomesticCcnlOutput = {}; @@ -375,7 +396,7 @@ export async function getDomesticOverview( function toNumber(value?: string) { if (!value) return 0; - const normalized = value.replaceAll(",", "").trim(); + const normalized = value.replace(/,/g, "").trim(); if (!normalized) return 0; const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : 0; @@ -383,7 +404,7 @@ function toNumber(value?: string) { function toOptionalNumber(value?: string) { if (!value) return undefined; - const normalized = value.replaceAll(",", "").trim(); + const normalized = value.replace(/,/g, "").trim(); if (!normalized) return undefined; const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : undefined; @@ -454,24 +475,13 @@ function formatDate(date: string) { return `${date.slice(4, 6)}/${date.slice(6, 8)}`; } -function getDomesticMarketPhaseInKst(now = new Date()): DomesticMarketPhase { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: "Asia/Seoul", - weekday: "short", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }).formatToParts(now); - - const partMap = new Map(parts.map((part) => [part.type, part.value])); - const weekday = partMap.get("weekday"); - const hour = Number(partMap.get("hour") ?? "0"); - const minute = Number(partMap.get("minute") ?? "0"); - const totalMinutes = hour * 60 + minute; - - if (weekday === "Sat" || weekday === "Sun") return "afterHours"; - if (totalMinutes >= 9 * 60 && totalMinutes < 15 * 60 + 30) return "regular"; - return "afterHours"; +function getDomesticMarketPhaseInKst( + now = new Date(), + sessionOverride?: string | null, +): DomesticMarketPhase { + return mapDomesticKisSessionToMarketPhase( + resolveDomesticKisSession(sessionOverride, now), + ); } function firstDefinedNumber(...values: Array) { @@ -629,7 +639,7 @@ function mergeCandlesByTimestamp(rows: StockCandlePoint[]) { volume: (prev.volume ?? 0) + (row.volume ?? 0), }); } - return [...map.values()].sort( + return Array.from(map.values()).sort( (a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0), ); } @@ -699,12 +709,43 @@ function minutesForTimeframe(tf: DashboardChartTimeframe) { return 1; } +/** + * 국내주식 주식일별분봉조회 (과거 분봉) + * @param symbol 종목코드 + * @param date 조회할 날짜 (YYYYMMDD) + * @param time 조회할 기준 시간 (HHMMSS) - 이 시간부터 과거로 조회 + * @param credentials + */ +export async function getDomesticDailyTimeChart( + symbol: string, + date: string, + time: string, + credentials?: KisCredentialInput, +) { + const response = await kisGet( + "/uapi/domestic-stock/v1/quotations/inquire-time-dailychartprice", + "FHKST03010230", + { + FID_COND_MRKT_DIV_CODE: "J", + FID_INPUT_ISCD: symbol, + FID_INPUT_DATE_1: date, + FID_INPUT_HOUR_1: time, + FID_PW_DATA_INCU_YN: "N", + FID_FAKE_TICK_INCU_YN: "", + }, + credentials, + ); + + return parseOutput2Rows(response); +} + // ─── 차트 데이터 조회 메인 ───────────────────────────────── /** * 종목 차트 데이터 조회 (일봉/주봉/분봉) * - 일봉/주봉: inquire-daily-itemchartprice (FHKST03010100), 과거 페이징 지원 - * - 분봉: inquire-time-itemchartprice (FHKST03010200), **당일 데이터만** 제공 + * - 분봉 (오늘): inquire-time-itemchartprice (FHKST03010200) + * - 분봉 (과거): inquire-time-dailychartprice (FHKST03010230) */ export async function getDomesticChart( symbol: string, @@ -743,7 +784,7 @@ export async function getDomesticChart( new Date(oldest.timestamp * 1000) .toISOString() .slice(0, 10) - .replaceAll("-", ""), + .replace(/-/g, ""), -1, ) : null; @@ -751,27 +792,100 @@ export async function getDomesticChart( return { candles: parsed, hasMore: Boolean(nextCursor), nextCursor }; } - // ── 분봉 (1m / 30m / 1h) — 당일 데이터만 제공 ── - const response = await kisGet( - "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice", - "FHKST03010200", - { - FID_COND_MRKT_DIV_CODE: "J", - FID_INPUT_ISCD: symbol, - FID_INPUT_HOUR_1: nowHmsInKst(), - FID_PW_DATA_INCU_YN: "Y", - FID_ETC_CLS_CODE: "", - }, - credentials, - ); - + // ── 분봉 (1m / 30m / 1h) ── const minuteBucket = minutesForTimeframe(timeframe); + let rawRows: KisDomesticItemChartRow[] = []; + let nextCursor: string | null = null; + + // Case A: 과거 데이터 조회 (커서 존재) + if (cursor && cursor.length >= 8) { + const targetDate = cursor.slice(0, 8); + const targetTime = cursor.slice(8) || "153000"; + rawRows = await getDomesticDailyTimeChart( + symbol, + targetDate, + targetTime, + credentials, + ); + + // 다음 커서 계산 + // 데이터가 있으면 가장 오래된 시간 - 1분? 혹은 해당 날짜의 09:00 도달 시 전일로 이동 + // API가 시간 역순으로 데이터를 준다고 가정 (output[0]이 가장 최신, output[last]가 가장 과거) + // 실제 KIS API는 보통 최신순 정렬 + if (rawRows.length > 0) { + // 가장 과거 데이터의 시간 확인 + const oldestRow = rawRows[rawRows.length - 1]; // 마지막이 가장 과거라 가정 + const oldestTime = readRowString(oldestRow, "stck_cntg_hour"); + + // 09:00:00보다 크면 계속 같은 날짜 페이징 (단, KIS가 120건씩 주므로) + // 만약 09시 근처라면 전일로 이동 + // 간단히: 가져온 데이터 중 090000이 포함되어 있거나, 더 가져올 게 없어 보이면 전일로 + // 여기서는 단순히 전일 153000으로 넘어가는 로직을 사용하거나, + // 현재 날짜에서 시간을 줄여서 재요청해야 함. + // KIS API가 '다음 커서'를 주지 않으므로, 마지막 데이터 시간을 기준으로 다음 요청 + + if (oldestTime && Number(oldestTime) > 90000) { + // 같은 날짜, 시간만 조정 (1분 전) + // HHMMSS -> number -> subtract -> string + // 편의상 120개 꽉 찼으면 마지막 시간 사용, 아니면 전일로 + if (rawRows.length >= 120) { + nextCursor = targetDate + oldestTime; // 다음 요청 시 이 시간 '이전'을 달라고 해야 함 (Inclusive 여부 확인 필요) + // 만약 Inclusive라면 -1분 해야 함. 안전하게 -1분 처리 + // 시간 연산이 복잡하므로, 단순히 전일로 넘어가는 게 나을 수도 있으나, + // 하루치 분봉이 380개라 120개로는 부족함. + // 따라서 시간 연산 필요. + nextCursor = targetDate + subOneMinute(oldestTime); + } else { + // 120개 미만이면 장 시작까지 다 가져왔다고 가정 -> 전일로 + nextCursor = shiftYmd(targetDate, -1) + "153000"; + } + } else { + // 09:00 도달 -> 전일로 + nextCursor = shiftYmd(targetDate, -1) + "153000"; + } + } else { + // 데이터 없음 (휴장일 등) -> 전일로 계속 시도 (최대 5일? 무한 루프 방지 필요하나 UI에서 제어) + nextCursor = shiftYmd(targetDate, -1) + "153000"; + // 너무 과거(1년)면 중단? 일단 생략 + } + + } else { + // Case B: 초기 진입 (오늘 실시간/장중 데이터) + const response = await kisGet( + "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice", + "FHKST03010200", + { + FID_COND_MRKT_DIV_CODE: "J", + FID_INPUT_ISCD: symbol, + FID_INPUT_HOUR_1: nowHmsInKst(), + FID_PW_DATA_INCU_YN: "Y", + FID_ETC_CLS_CODE: "", + }, + credentials, + ); + rawRows = parseOutput2Rows(response); + + // 오늘 데이터 다음은 '어제 마감' + const todayYmd = nowYmdInKst(); + nextCursor = shiftYmd(todayYmd, -1) + "153000"; + } + const candles = mergeCandlesByTimestamp( - parseOutput2Rows(response) + rawRows .map((row) => parseMinuteCandleRow(row, minuteBucket)) .filter((c): c is StockCandlePoint => Boolean(c)), ); - // 당일 분봉만 제공되므로 과거 페이징 불필요 - return { candles, hasMore: false, nextCursor: null }; + return { candles, hasMore: Boolean(nextCursor), nextCursor }; +} + +function subOneMinute(hhmmss: string) { + const hh = Number(hhmmss.slice(0, 2)); + const mm = Number(hhmmss.slice(2, 4)); + let totalMin = hh * 60 + mm - 1; + if (totalMin < 0) totalMin = 0; + + const h = Math.floor(totalMin / 60); + const m = totalMin % 60; + return `${String(h).padStart(2, '0')}${String(m).padStart(2, '0')}00`; }