import { useEffect, useRef, useState } from "react"; import { type KisRuntimeCredentials, useKisRuntimeStore, } from "@/features/dashboard/store/use-kis-runtime-store"; 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"; // ─── 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 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"]) { if (env === "mock") return TRADE_TR_ID; return isOvertimeHours() ? TRADE_TR_ID_OVERTIME : TRADE_TR_ID; } function resolveOrderBookTrId() { return isOvertimeHours() ? ORDERBOOK_TR_ID_OVERTIME : ORDERBOOK_TR_ID; } // ─── 메인 훅 ──────────────────────────────────────────── /** * 통합 실시간 웹소켓 훅 — 체결(H0STCNT0) + 호가(H0STASP0)를 단일 WS로 수신합니다. * * @param symbol 종목코드 * @param credentials KIS 인증 정보 * @param isVerified 인증 완료 여부 * @param onTick 체결 콜백 (StockHeader 갱신용) * @param options.orderBookSymbol 호가 구독 종목코드 * @param options.onOrderBookMessage 호가 수신 콜백 */ export function useKisTradeWebSocket( symbol: string | undefined, credentials: KisRuntimeCredentials | null, isVerified: boolean, onTick?: (tick: DashboardRealtimeTradeTick) => void, options?: { orderBookSymbol?: string; onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void; }, ) { 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 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; // 8초간 데이터 없을 시 안내 메시지 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); seenTickRef.current.clear(); if (!symbol || !isVerified || !credentials) { socketRef.current?.close(); socketRef.current = null; approvalKeyRef.current = null; setIsConnected(false); return; } let disposed = false; let socket: WebSocket | null = null; const currentTrId = resolveTradeTrId(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 wsBase = process.env.NEXT_PUBLIC_KIS_WS_URL || "ws://ops.koreainvestment.com:21000"; socket = new WebSocket(`${wsBase}/tryitout/${currentTrId}`); socketRef.current = socket; // ── onopen: 체결 + 호가 구독 ── socket.onopen = () => { if (disposed || !approvalKeyRef.current) return; socket?.send( JSON.stringify( buildKisRealtimeMessage( approvalKeyRef.current, symbol, currentTrId, "1", ), ), ); if (obSymbol && obTrId) { socket?.send( JSON.stringify( buildKisRealtimeMessage( approvalKeyRef.current, obSymbol, obTrId, "1", ), ), ); } 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); 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}`; 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) { setRecentTradeTicks((prev) => [...deduped.reverse(), ...prev].slice(0, MAX_TRADE_TICKS), ); } setError(null); setLastTickAt(Date.now()); onTick?.(latest); }; socket.onerror = () => { if (!disposed) setIsConnected(false); }; socket.onclose = () => { if (!disposed) setIsConnected(false); }; } catch (err) { if (disposed) return; setError( err instanceof Error ? err.message : "실시간 웹소켓 초기화 중 오류가 발생했습니다.", ); setIsConnected(false); } }; void connect(); const seenRef = seenTickRef.current; // ── cleanup: 구독 해제 ── return () => { disposed = true; setIsConnected(false); const key = approvalKeyRef.current; if (socket?.readyState === WebSocket.OPEN && key) { socket.send( JSON.stringify( buildKisRealtimeMessage(key, symbol, currentTrId, "2"), ), ); if (obSymbol && obTrId) { socket.send( JSON.stringify(buildKisRealtimeMessage(key, obSymbol, obTrId, "2")), ); } } socket?.close(); if (socketRef.current === socket) socketRef.current = null; approvalKeyRef.current = null; seenRef.clear(); }; }, [ isVerified, symbol, credentials, onTick, obSymbol, obTrId, onOrderBookMsg, ]); return { latestTick, realtimeCandles, recentTradeTicks, isConnected, error, lastTickAt, realtimeTrId: trId ?? TRADE_TR_ID, }; }