"use client"; import { FormEvent, useCallback, useEffect, useRef, useState, useTransition } from "react"; import { Activity, Search, ShieldCheck, TrendingDown, TrendingUp } from "lucide-react"; import { useShallow } from "zustand/react/shallow"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { useKisRuntimeStore, type KisRuntimeCredentials, } from "@/features/dashboard/store/use-kis-runtime-store"; import type { DashboardMarketPhase, DashboardKisRevokeResponse, DashboardPriceSource, DashboardKisValidateResponse, DashboardKisWsApprovalResponse, DashboardStockItem, DashboardStockOverviewResponse, DashboardStockSearchItem, DashboardStockSearchResponse, StockCandlePoint, } from "@/features/dashboard/types/dashboard.types"; /** * @file features/dashboard/components/dashboard-main.tsx * @description 대시보드 메인 UI(검색/시세/차트) */ const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR"); function formatPrice(value: number) { return `${PRICE_FORMATTER.format(value)}원`; } function formatVolume(value: number) { return `${PRICE_FORMATTER.format(value)}주`; } function getPriceSourceLabel(source: DashboardPriceSource, marketPhase: DashboardMarketPhase) { switch (source) { case "inquire-overtime-price": return "시간외 현재가(inquire-overtime-price)"; case "inquire-ccnl": return marketPhase === "afterHours" ? "체결가 폴백(inquire-ccnl)" : "체결가(inquire-ccnl)"; default: return "현재가(inquire-price)"; } } function getMarketPhaseLabel(marketPhase: DashboardMarketPhase) { return marketPhase === "regular" ? "장중(한국시간 09:00~15:30)" : "장외/휴장"; } /** * 주가 라인 차트(SVG) */ function StockLineChart({ candles }: { candles: StockCandlePoint[] }) { const chart = (() => { const width = 760; const height = 280; const paddingX = 24; const paddingY = 20; const plotWidth = width - paddingX * 2; const plotHeight = height - paddingY * 2; const prices = candles.map((item) => item.price); const minPrice = Math.min(...prices); const maxPrice = Math.max(...prices); const range = Math.max(maxPrice - minPrice, 1); const points = candles.map((item, index) => { const x = paddingX + (index / Math.max(candles.length - 1, 1)) * plotWidth; const y = paddingY + ((maxPrice - item.price) / range) * plotHeight; return { x, y }; }); const linePoints = points.map((point) => `${point.x},${point.y}`).join(" "); const firstPoint = points[0]; const lastPoint = points[points.length - 1]; const areaPoints = `${linePoints} ${lastPoint.x},${height - paddingY} ${firstPoint.x},${height - paddingY}`; return { width, height, paddingX, paddingY, minPrice, maxPrice, linePoints, areaPoints }; })(); return (
{candles[0]?.time} 저가 {formatPrice(chart.minPrice)} 고가 {formatPrice(chart.maxPrice)} {candles[candles.length - 1]?.time}
); } function PriceStat({ label, value }: { label: string; value: string }) { return (

{label}

{value}

); } async function fetchStockSearch(keyword: string) { const response = await fetch(`/api/kis/domestic/search?q=${encodeURIComponent(keyword)}`, { cache: "no-store", }); const payload = (await response.json()) as DashboardStockSearchResponse | { error?: string }; if (!response.ok) { throw new Error("error" in payload ? payload.error : "종목 검색 중 오류가 발생했습니다."); } return payload as DashboardStockSearchResponse; } async function fetchStockOverview(symbol: string, credentials: KisRuntimeCredentials) { const response = await fetch(`/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, }, cache: "no-store", }); const payload = (await response.json()) as DashboardStockOverviewResponse | { error?: string }; if (!response.ok) { throw new Error("error" in payload ? payload.error : "종목 조회 중 오류가 발생했습니다."); } return payload as DashboardStockOverviewResponse; } async function validateKisCredentials(credentials: KisRuntimeCredentials) { const response = await fetch("/api/kis/validate", { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify(credentials), cache: "no-store", }); const payload = (await response.json()) as DashboardKisValidateResponse; if (!response.ok || !payload.ok) { throw new Error(payload.message || "API 키 검증에 실패했습니다."); } return payload; } /** * KIS 접근토큰 폐기 요청 * @param credentials 검증 완료된 KIS 키 * @returns 폐기 응답 * @see app/api/kis/revoke/route.ts POST - revokeP 폐기 프록시 */ async function revokeKisCredentials(credentials: KisRuntimeCredentials) { const response = await fetch("/api/kis/revoke", { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify(credentials), cache: "no-store", }); const payload = (await response.json()) as DashboardKisRevokeResponse; if (!response.ok || !payload.ok) { throw new Error(payload.message || "API 키 접근 폐기에 실패했습니다."); } return payload; } const KIS_REALTIME_TR_ID_REAL = "H0UNCNT0"; const KIS_REALTIME_TR_ID_MOCK = "H0STCNT0"; function resolveRealtimeTrId(tradingEnv: KisRuntimeCredentials["tradingEnv"]) { return tradingEnv === "mock" ? KIS_REALTIME_TR_ID_MOCK : KIS_REALTIME_TR_ID_REAL; } /** * KIS 실시간 웹소켓 승인키를 발급받습니다. * @param credentials 검증 완료된 KIS 키 * @returns approval key + ws url * @see app/api/kis/ws/approval/route.ts POST - Approval 발급 프록시 */ async function fetchKisWebSocketApproval(credentials: KisRuntimeCredentials) { const response = await fetch("/api/kis/ws/approval", { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify(credentials), cache: "no-store", }); const payload = (await response.json()) as DashboardKisWsApprovalResponse; if (!response.ok || !payload.ok || !payload.approvalKey || !payload.wsUrl) { throw new Error(payload.message || "KIS 실시간 웹소켓 승인키 발급에 실패했습니다."); } return payload; } /** * KIS 실시간 체결가 구독/해제 메시지를 생성합니다. * @param approvalKey websocket 승인키 * @param symbol 종목코드 * @param trType "1"(구독) | "2"(해제) * @returns websocket 요청 메시지 * @see https://github.com/koreainvestment/open-trading-api */ function buildKisRealtimeMessage( approvalKey: string, symbol: string, trId: string, trType: "1" | "2", ) { return { header: { approval_key: approvalKey, custtype: "P", tr_type: trType, "content-type": "utf-8", }, body: { input: { tr_id: trId, tr_key: symbol, }, }, }; } interface KisRealtimeTick { point: StockCandlePoint; price: number; accumulatedVolume: number; tickTime: string; } /** * KIS 실시간 체결가 원문을 차트 포인트로 변환합니다. * @param raw websocket 수신 원문 * @param expectedSymbol 현재 선택 종목코드 * @returns 실시간 포인트 또는 null */ function parseKisRealtimeTick(raw: string, expectedSymbol: string, expectedTrId: string): KisRealtimeTick | null { if (!/^([01])\|/.test(raw)) return null; const parts = raw.split("|"); if (parts.length < 4) return null; if (parts[1] !== expectedTrId) return null; const tickCount = Number(parts[2] ?? "1"); const values = parts[3].split("^"); const isBatch = Number.isInteger(tickCount) && tickCount > 1 && values.length % tickCount === 0; const fieldsPerTick = isBatch ? values.length / tickCount : values.length; const baseIndex = isBatch ? (tickCount - 1) * fieldsPerTick : 0; const symbol = values[baseIndex]; const hhmmss = values[baseIndex + 1]; const price = Number((values[baseIndex + 2] ?? "").replaceAll(",", "").trim()); const accumulatedVolume = Number((values[baseIndex + 13] ?? "").replaceAll(",", "").trim()); if (symbol !== expectedSymbol) return null; if (!Number.isFinite(price) || price <= 0) return null; return { point: { time: formatRealtimeTickTime(hhmmss), price, }, price, accumulatedVolume: Number.isFinite(accumulatedVolume) && accumulatedVolume > 0 ? accumulatedVolume : 0, tickTime: hhmmss ?? "", }; } function formatRealtimeTickTime(hhmmss?: string) { if (!hhmmss || hhmmss.length !== 6) return "실시간"; return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`; } 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); } function toTickOrderValue(hhmmss?: string) { if (!hhmmss || !/^\d{6}$/.test(hhmmss)) return -1; return Number(hhmmss); } /** * 대시보드 메인 화면 */ export function DashboardMain() { // [State] KIS 키 입력/검증 상태(zustand + persist) const { kisTradingEnvInput, kisAppKeyInput, kisAppSecretInput, verifiedCredentials, isKisVerified, tradingEnv, setKisTradingEnvInput, setKisAppKeyInput, setKisAppSecretInput, setVerifiedKisSession, invalidateKisVerification, clearKisRuntimeSession, } = useKisRuntimeStore( useShallow((state) => ({ kisTradingEnvInput: state.kisTradingEnvInput, kisAppKeyInput: state.kisAppKeyInput, kisAppSecretInput: state.kisAppSecretInput, verifiedCredentials: state.verifiedCredentials, isKisVerified: state.isKisVerified, tradingEnv: state.tradingEnv, setKisTradingEnvInput: state.setKisTradingEnvInput, setKisAppKeyInput: state.setKisAppKeyInput, setKisAppSecretInput: state.setKisAppSecretInput, setVerifiedKisSession: state.setVerifiedKisSession, invalidateKisVerification: state.invalidateKisVerification, clearKisRuntimeSession: state.clearKisRuntimeSession, })), ); // [State] 검증 상태 메시지 const [kisStatusMessage, setKisStatusMessage] = useState(null); const [kisStatusError, setKisStatusError] = useState(null); // [State] 검색/선택 데이터 const [keyword, setKeyword] = useState("삼성전자"); const [searchResults, setSearchResults] = useState([]); const [selectedStock, setSelectedStock] = useState(null); const [selectedOverviewMeta, setSelectedOverviewMeta] = useState<{ priceSource: DashboardPriceSource; marketPhase: DashboardMarketPhase; fetchedAt: string; } | null>(null); const [realtimeCandles, setRealtimeCandles] = useState([]); const [isRealtimeConnected, setIsRealtimeConnected] = useState(false); const [realtimeError, setRealtimeError] = useState(null); const [lastRealtimeTickAt, setLastRealtimeTickAt] = useState(null); const [realtimeTickCount, setRealtimeTickCount] = useState(0); // [State] 영역별 에러 const [searchError, setSearchError] = useState(null); const [overviewError, setOverviewError] = useState(null); // [State] 비동기 전환 상태 const [isValidatingKis, startValidateTransition] = useTransition(); const [isRevokingKis, startRevokeTransition] = useTransition(); const [isSearching, startSearchTransition] = useTransition(); const [isLoadingOverview, startOverviewTransition] = useTransition(); const realtimeSocketRef = useRef(null); const realtimeApprovalKeyRef = useRef(null); const lastRealtimeTickOrderRef = useRef(-1); const isPositive = (selectedStock?.change ?? 0) >= 0; const chartCandles = isRealtimeConnected && realtimeCandles.length > 0 ? realtimeCandles : (selectedStock?.candles ?? []); const apiPriceSourceLabel = selectedOverviewMeta ? getPriceSourceLabel(selectedOverviewMeta.priceSource, selectedOverviewMeta.marketPhase) : null; const realtimeTrId = verifiedCredentials ? resolveRealtimeTrId(verifiedCredentials.tradingEnv) : null; const effectivePriceSourceLabel = isRealtimeConnected && lastRealtimeTickAt ? `실시간 체결(WebSocket ${realtimeTrId ?? KIS_REALTIME_TR_ID_REAL})` : apiPriceSourceLabel; useEffect(() => { setRealtimeCandles([]); setIsRealtimeConnected(false); setRealtimeError(null); setLastRealtimeTickAt(null); setRealtimeTickCount(0); lastRealtimeTickOrderRef.current = -1; }, [selectedStock?.symbol]); useEffect(() => { if (!isRealtimeConnected || lastRealtimeTickAt) return; const noTickTimer = window.setTimeout(() => { setRealtimeError("실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요."); }, 8000); return () => { window.clearTimeout(noTickTimer); }; }, [isRealtimeConnected, lastRealtimeTickAt]); useEffect(() => { const symbol = selectedStock?.symbol; if (!symbol || !isKisVerified || !verifiedCredentials) { setIsRealtimeConnected(false); setRealtimeError(null); setRealtimeTickCount(0); lastRealtimeTickOrderRef.current = -1; realtimeSocketRef.current?.close(); realtimeSocketRef.current = null; realtimeApprovalKeyRef.current = null; return; } let disposed = false; let socket: WebSocket | null = null; const realtimeTrId = resolveRealtimeTrId(verifiedCredentials.tradingEnv); const connectKisRealtimePrice = async () => { try { setRealtimeError(null); setIsRealtimeConnected(false); const approval = await fetchKisWebSocketApproval(verifiedCredentials); if (disposed) return; realtimeApprovalKeyRef.current = approval.approvalKey ?? null; socket = new WebSocket(`${approval.wsUrl}/tryitout`); realtimeSocketRef.current = socket; socket.onopen = () => { if (disposed || !realtimeApprovalKeyRef.current) return; const subscribeMessage = buildKisRealtimeMessage(realtimeApprovalKeyRef.current, symbol, realtimeTrId, "1"); socket?.send(JSON.stringify(subscribeMessage)); setIsRealtimeConnected(true); }; socket.onmessage = (event) => { if (disposed || typeof event.data !== "string") return; const tick = parseKisRealtimeTick(event.data, symbol, realtimeTrId); if (!tick) return; // 지연 도착으로 시간이 역행하는 틱은 무시해 차트 흔들림을 줄입니다. const nextTickOrder = toTickOrderValue(tick.tickTime); if (nextTickOrder > 0 && lastRealtimeTickOrderRef.current > nextTickOrder) { return; } if (nextTickOrder > 0) { lastRealtimeTickOrderRef.current = nextTickOrder; } setRealtimeError(null); setLastRealtimeTickAt(Date.now()); setRealtimeTickCount((prev) => prev + 1); setRealtimeCandles((prev) => appendRealtimeTick(prev, tick.point)); // 실시간 체결가를 카드 현재가/등락/거래량에도 반영합니다. setSelectedStock((prev) => { if (!prev || prev.symbol !== symbol) return prev; const nextPrice = tick.price; const nextChange = nextPrice - prev.prevClose; const nextChangeRate = prev.prevClose > 0 ? (nextChange / prev.prevClose) * 100 : prev.changeRate; const nextHigh = prev.high > 0 ? Math.max(prev.high, nextPrice) : nextPrice; const nextLow = prev.low > 0 ? Math.min(prev.low, nextPrice) : nextPrice; return { ...prev, currentPrice: nextPrice, change: nextChange, changeRate: nextChangeRate, high: nextHigh, low: nextLow, volume: tick.accumulatedVolume > 0 ? tick.accumulatedVolume : prev.volume, }; }); }; socket.onerror = () => { if (disposed) return; setIsRealtimeConnected(false); setRealtimeError("실시간 연결 중 오류가 발생했습니다."); }; socket.onclose = () => { if (disposed) return; setIsRealtimeConnected(false); }; } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : "실시간 웹소켓 초기화 중 오류가 발생했습니다."; setRealtimeError(message); setIsRealtimeConnected(false); } }; void connectKisRealtimePrice(); return () => { disposed = true; setIsRealtimeConnected(false); const approvalKey = realtimeApprovalKeyRef.current; if (socket?.readyState === WebSocket.OPEN && approvalKey) { const unsubscribeMessage = buildKisRealtimeMessage(approvalKey, symbol, realtimeTrId, "2"); socket.send(JSON.stringify(unsubscribeMessage)); } socket?.close(); if (realtimeSocketRef.current === socket) { realtimeSocketRef.current = null; } realtimeApprovalKeyRef.current = null; }; }, [ isKisVerified, selectedStock?.symbol, verifiedCredentials, ]); const loadOverview = useCallback( async (symbol: string, credentials: KisRuntimeCredentials) => { try { setOverviewError(null); const data = await fetchStockOverview(symbol, credentials); setSelectedStock(data.stock); setSelectedOverviewMeta({ priceSource: data.priceSource, marketPhase: data.marketPhase, fetchedAt: data.fetchedAt, }); } catch (error) { const message = error instanceof Error ? error.message : "종목 조회 중 오류가 발생했습니다."; setOverviewError(message); setSelectedOverviewMeta(null); } }, [], ); const loadSearch = useCallback( async (nextKeyword: string, credentials: KisRuntimeCredentials, pickFirst = false) => { try { setSearchError(null); const data = await fetchStockSearch(nextKeyword); setSearchResults(data.items); if (pickFirst && data.items[0]) { await loadOverview(data.items[0].symbol, credentials); } } catch (error) { const message = error instanceof Error ? error.message : "종목 검색 중 오류가 발생했습니다."; setSearchError(message); } }, [loadOverview], ); function handleSubmit(event: FormEvent) { event.preventDefault(); if (!isKisVerified || !verifiedCredentials) { setSearchError("상단에서 API 키 검증을 먼저 완료해 주세요."); return; } startSearchTransition(() => { void loadSearch(keyword, verifiedCredentials, true); }); } function handlePickStock(item: DashboardStockSearchItem) { if (!isKisVerified || !verifiedCredentials) { setSearchError("상단에서 API 키 검증을 먼저 완료해 주세요."); return; } setKeyword(item.name); startOverviewTransition(() => { void loadOverview(item.symbol, verifiedCredentials); }); } function handleValidateKis() { startValidateTransition(() => { void (async () => { try { setKisStatusError(null); setKisStatusMessage(null); const trimmedAppKey = kisAppKeyInput.trim(); const trimmedAppSecret = kisAppSecretInput.trim(); if (!trimmedAppKey || !trimmedAppSecret) { throw new Error("앱 키와 앱 시크릿을 모두 입력해 주세요."); } const credentials: KisRuntimeCredentials = { appKey: trimmedAppKey, appSecret: trimmedAppSecret, tradingEnv: kisTradingEnvInput, }; const result = await validateKisCredentials(credentials); setVerifiedKisSession(credentials, result.tradingEnv); setKisStatusMessage( `${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"} 모드)`, ); startSearchTransition(() => { void loadSearch(keyword || "삼성전자", credentials, true); }); } catch (error) { const message = error instanceof Error ? error.message : "API 키 검증 중 오류가 발생했습니다."; invalidateKisVerification(); setSearchResults([]); setSelectedStock(null); setSelectedOverviewMeta(null); setKisStatusError(message); } })(); }); } function handleRevokeKis() { if (!verifiedCredentials) { setKisStatusError("먼저 API 키 검증을 완료해 주세요."); return; } startRevokeTransition(() => { void (async () => { try { // 접근 폐기 전, 화면 상태 메시지를 초기화합니다. setKisStatusError(null); setKisStatusMessage(null); const result = await revokeKisCredentials(verifiedCredentials); // 로그아웃처럼 검증/조회 상태를 초기화합니다. clearKisRuntimeSession(result.tradingEnv); setSearchResults([]); setSelectedStock(null); setSelectedOverviewMeta(null); setSearchError(null); setOverviewError(null); setKisStatusMessage(`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"} 모드)`); } catch (error) { const message = error instanceof Error ? error.message : "API 키 접근 폐기 중 오류가 발생했습니다."; setKisStatusError(message); } })(); }); } return (
{/* ========== KIS KEY VERIFY SECTION ========== */}
KIS API 키 연결 대시보드 사용 전, 개인 API 키를 입력하고 검증해 주세요. 검증에 성공해야 시세 조회가 동작합니다.
setKisAppKeyInput(event.target.value)} placeholder="앱 키 입력" autoComplete="off" />
setKisAppSecretInput(event.target.value)} placeholder="앱 시크릿 입력" autoComplete="off" />
{isKisVerified ? ( 검증 완료 ({tradingEnv === "real" ? "실전" : "모의"}) ) : ( 미검증 )}
{kisStatusError ?

{kisStatusError}

: null} {kisStatusMessage ?

{kisStatusMessage}

: null}
입력한 API 키는 새로고침 유지를 위해 현재 브라우저 저장소(zustand persist)에만 보관되며, 접근 폐기를 누르면 즉시 초기화됩니다.
{/* ========== DASHBOARD TITLE SECTION ========== */}

국내주식 대시보드

종목명 검색, 현재가, 일자별 차트를 한 화면에서 확인합니다.

{/* ========== STOCK SEARCH SECTION ========== */}
종목 검색 종목명 또는 종목코드(예: 삼성전자, 005930)로 검색할 수 있습니다.
setKeyword(event.target.value)} placeholder="종목명 / 종목코드 검색" className="pl-9" disabled={!isKisVerified} />
{!isKisVerified ? (

상단에서 API 키 검증을 완료해야 검색/시세 기능이 동작합니다.

) : searchError ? (

{searchError}

) : (

{searchResults.length > 0 ? `검색 결과 ${searchResults.length}개` : "검색어를 입력하고 엔터를 누르면 종목이 표시됩니다."}

)}
{searchResults.map((item) => { const active = item.symbol === selectedStock?.symbol; return ( ); })}
{/* ========== STOCK OVERVIEW SECTION ========== */}
{selectedStock?.name ?? "종목을 선택해 주세요"} {selectedStock ? `${selectedStock.symbol} · ${selectedStock.market}` : "선택된 종목이 없습니다."}
{selectedStock && (
{isPositive ? : } {isPositive ? "+" : ""} {selectedStock.change.toLocaleString()} ({isPositive ? "+" : ""} {selectedStock.changeRate.toFixed(2)}%)
)}
{overviewError ? (

{overviewError}

) : !isKisVerified ? (

상단에서 API 키 검증을 완료해 주세요.

) : isLoadingOverview && !selectedStock ? (

종목 데이터를 불러오는 중입니다...

) : selectedStock ? ( <>

{formatPrice(selectedStock.currentPrice)}

{effectivePriceSourceLabel && selectedOverviewMeta ? (
현재가 소스: {effectivePriceSourceLabel} 구간: {getMarketPhaseLabel(selectedOverviewMeta.marketPhase)} 조회시각: {new Date(selectedOverviewMeta.fetchedAt).toLocaleTimeString("ko-KR")}
) : null} {realtimeError ?

{realtimeError}

: null} ) : (

종목을 선택하면 시세와 차트가 표시됩니다.

)}
핵심 지표 연동 상태

국내주식 {tradingEnv === "real" ? "실전" : "모의"}투자 API 연결 완료

실시간 체결가 연결 상태:{" "} {isRealtimeConnected ? "연결됨 (WebSocket)" : "대기 중 (일봉 차트 표시)"}

마지막 실시간 수신: {lastRealtimeTickAt ? new Date(lastRealtimeTickAt).toLocaleTimeString("ko-KR") : "수신 전"}

실시간 틱 수신 수: {realtimeTickCount.toLocaleString("ko-KR")}건

다음 단계: 주문/리스크 제어 API 연결

); }