import { useEffect, useRef, useState } from "react"; import { type KisRuntimeCredentials, useKisRuntimeStore, } from "@/features/dashboard/store/use-kis-runtime-store"; import type { DashboardRealtimeTradeTick, StockCandlePoint, } from "@/features/dashboard/types/dashboard.types"; import { appendRealtimeTick, buildKisRealtimeMessage, parseKisRealtimeTickBatch, toTickOrderValue, } from "@/features/dashboard/utils/kis-realtime.utils"; const KIS_REALTIME_TR_ID_REAL = "H0STCNT0"; const KIS_REALTIME_TR_ID_OVERTIME = "H0STOUP0"; // 시간외 단일가 const KIS_REALTIME_TR_ID_MOCK = "H0STCNT0"; const MAX_TRADE_TICKS = 10; // 체결 테이프용 최대 개수 function resolveRealtimeTrId(tradingEnv: KisRuntimeCredentials["tradingEnv"]) { if (tradingEnv === "mock") return KIS_REALTIME_TR_ID_MOCK; // 현재 시간(KST) 체크 - 사용자 브라우저 시간 기준 const now = new Date(); const hours = now.getHours(); const minutes = now.getMinutes(); const time = hours * 100 + minutes; // 16:00 ~ 18:00 사이는 시간외 단일가 TR ID 사용 if (time >= 1600 && time < 1800) { return KIS_REALTIME_TR_ID_OVERTIME; } return KIS_REALTIME_TR_ID_REAL; } /** * @description 통합 실시간 체결 웹소켓 훅 (H0STCNT0) * - StockHeader: 실시간 현재가, 등락률, 시가/고가/저가/거래량 * - StockLineChart: 실시간 캔들 (분봉/일봉 등) * - OrderBook (TradeTape): 최근 체결 내역 리스트 */ export function useKisTradeWebSocket( symbol: string | undefined, credentials: KisRuntimeCredentials | null, isVerified: boolean, onTick?: (tick: DashboardRealtimeTradeTick) => void, ) { // 1. StockHeader용 최신 데이터 const [latestTick, setLatestTick] = useState(null); // 2. StockLineChart용 캔들 데이터 const [realtimeCandles, setRealtimeCandles] = useState( [], ); // 3. TradeTape용 최근 체결 리스트 const [recentTradeTicks, setRecentTradeTicks] = useState< DashboardRealtimeTradeTick[] >([]); // 연결 상태 const [isConnected, setIsConnected] = useState(false); const [error, setError] = useState(null); const [lastTickAt, setLastTickAt] = useState(null); const socketRef = useRef(null); const approvalKeyRef = useRef(null); const lastTickOrderRef = useRef(-1); const seenTickRef = useRef>(new Set()); const realtimeTrId = credentials ? resolveRealtimeTrId(credentials.tradingEnv) : null; // 데이터 없음 감지 (8초) useEffect(() => { if (!isConnected || lastTickAt) return; const noTickTimer = window.setTimeout(() => { setError( "실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요.", ); }, 8000); return () => { window.clearTimeout(noTickTimer); }; }, [isConnected, lastTickAt]); // 웹소켓 연결 로직 useEffect(() => { // 초기화 setLatestTick(null); setRealtimeCandles([]); setRecentTradeTicks([]); setError(null); seenTickRef.current.clear(); if (!symbol || !isVerified || !credentials) { if (socketRef.current) { socketRef.current.close(); socketRef.current = null; } approvalKeyRef.current = null; setIsConnected(false); return; } let disposed = false; let socket: WebSocket | null = null; const trId = resolveRealtimeTrId(credentials.tradingEnv); const connect = async () => { try { setError(null); setIsConnected(false); const approvalKey = await useKisRuntimeStore .getState() .getOrFetchApprovalKey(); if (!approvalKey) { throw new Error("웹소켓 승인키 발급에 실패했습니다."); } if (disposed) return; approvalKeyRef.current = approvalKey; const wsBaseUrl = process.env.NEXT_PUBLIC_KIS_WS_URL || "ws://ops.koreainvestment.com:21000"; socket = new WebSocket(`${wsBaseUrl}/tryitout/${trId}`); socketRef.current = socket; console.log("[WS URL]", `${wsBaseUrl}/tryitout/${trId}`); socket.onopen = () => { if (disposed || !approvalKeyRef.current) return; console.log("[WS Open] Connected"); const subscribeMessage = buildKisRealtimeMessage( approvalKeyRef.current, symbol, trId, "1", ); socket?.send(JSON.stringify(subscribeMessage)); setIsConnected(true); }; socket.onmessage = (event) => { if (disposed || typeof event.data !== "string") return; const parsedTicks = parseKisRealtimeTickBatch(event.data, symbol); if (parsedTicks.length === 0) { console.log( "[WS Parsed] No ticks found. Check TrId/Symbol match.", { symbol, trId }, ); return; } // 1. 데이터 정제 및 중복 제거 (TradeTape용) const meaningfulTicks = parsedTicks.filter( (tick) => tick.tradeVolume > 0, ); const dedupedForTape = meaningfulTicks.filter((tick) => { const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`; if (seenTickRef.current.has(key)) return false; seenTickRef.current.add(key); return true; }); // 2. 최신 틱 업데이트 (StockHeader용) // 배치 중 가장 마지막(최신) 틱을 사용 const lastTickInBatch = parsedTicks[parsedTicks.length - 1]; setLatestTick(lastTickInBatch); // 3. 캔들 업데이트 (StockLineChart용) // 지연 도착 틱 필터링 const nextTickOrder = toTickOrderValue(lastTickInBatch.tickTime); if (nextTickOrder > 0) { if (lastTickOrderRef.current <= nextTickOrder) { lastTickOrderRef.current = nextTickOrder; const candlePoint: StockCandlePoint = { time: formatTime(lastTickInBatch.tickTime), price: lastTickInBatch.price, // 필요한 경우 open, high, low, volume 등을 여기서 조합 가능 // 현재 차트 컴포넌트는 Point 단위로 price만 주로 씀 }; setRealtimeCandles((prev) => appendRealtimeTick(prev, candlePoint), ); } } // 4. 체결 테이프 업데이트 if (dedupedForTape.length > 0) { setRecentTradeTicks((prev) => [...dedupedForTape.reverse(), ...prev].slice(0, MAX_TRADE_TICKS), ); } // 5. 콜백 및 상태 업데이트 setError(null); setLastTickAt(Date.now()); // onTick 콜백용 데이터 구성 if (onTick) { onTick(lastTickInBatch); } }; socket.onerror = () => { if (disposed) return; setIsConnected(false); // setError("실시간 체결 연결 중 오류가 발생했습니다."); }; socket.onclose = () => { if (disposed) return; setIsConnected(false); }; } catch (err) { if (disposed) return; const message = err instanceof Error ? err.message : "실시간 웹소켓 초기화 중 오류가 발생했습니다."; setError(message); setIsConnected(false); } }; void connect(); const seenTickRefCurrent = seenTickRef.current; return () => { disposed = true; setIsConnected(false); const approvalKey = approvalKeyRef.current; if (socket?.readyState === WebSocket.OPEN && approvalKey) { const unsubscribeMessage = buildKisRealtimeMessage( approvalKey, symbol, trId, "2", ); socket.send(JSON.stringify(unsubscribeMessage)); } socket?.close(); if (socketRef.current === socket) { socketRef.current = null; } approvalKeyRef.current = null; seenTickRefCurrent.clear(); }; }, [isVerified, symbol, credentials, onTick]); return { latestTick, // Header용 realtimeCandles, // Chart용 recentTradeTicks, // Tape용 isConnected, error, lastTickAt, realtimeTrId: realtimeTrId ?? KIS_REALTIME_TR_ID_REAL, }; } function formatTime(hhmmss: string) { if (!hhmmss || hhmmss.length !== 6) return "실시간"; return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`; }