import { useEffect, useRef, useState } from "react"; import { type KisRuntimeCredentials, useKisRuntimeStore, } from "@/features/dashboard/store/use-kis-runtime-store"; import type { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types"; import { buildKisRealtimeMessage, parseKisRealtimeOrderbook, } from "@/features/dashboard/utils/kis-realtime.utils"; const KRX_ORDERBOOK_TR_ID = "H0STASP0"; const KRX_OVERTIME_ORDERBOOK_TR_ID = "H0STOAA0"; const DEFAULT_ORDERBOOK_TR_ID = "H0UNASP0"; /** * @description 한국 시간 기준으로 시간외 단일가 구간(16:00~18:00) 여부를 판별합니다. * @see features/dashboard/hooks/useKisOrderbookWebSocket.ts resolveOrderBookTrId에서 TR ID 선택 기준으로 사용합니다. */ function isKrxOvertimeInKst(now = new Date()) { const formatter = new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Seoul", weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false, }); const parts = formatter.formatToParts(now); const partMap = new Map(parts.map((part) => [part.type, part.value])); const weekday = partMap.get("weekday"); if (weekday === "Sat" || weekday === "Sun") { return false; } const hour = Number(partMap.get("hour") ?? "0"); const minute = Number(partMap.get("minute") ?? "0"); const totalMinutes = hour * 60 + minute; return totalMinutes >= 16 * 60 && totalMinutes < 18 * 60; } /** * @description 마켓/시간대에 맞는 국내주식 호가 TR ID를 반환합니다. * @see .tmp/open-trading-api/examples_user/domestic_stock/domestic_stock_functions_ws.py asking_price_krx(H0STASP0), overtime_asking_price_krx(H0STOAA0) 기준 */ function resolveOrderBookTrId(market: "KOSPI" | "KOSDAQ" | undefined) { if (market === "KOSPI" || market === "KOSDAQ") { return isKrxOvertimeInKst() ? KRX_OVERTIME_ORDERBOOK_TR_ID : KRX_ORDERBOOK_TR_ID; } return DEFAULT_ORDERBOOK_TR_ID; } /** * @description KIS 실시간 호가 웹소켓 훅 * @see .tmp/open-trading-api/examples_user/domestic_stock/domestic_stock_functions_ws.py KRX 호가(H0STASP0) / 통합 호가(H0UNASP0) 기준 */ export function useKisOrderbookWebSocket( symbol: string | undefined, market: "KOSPI" | "KOSDAQ" | undefined, credentials: KisRuntimeCredentials | null, isVerified: boolean, ) { const [realtimeOrderBook, setRealtimeOrderBook] = useState(null); const [isConnected, setIsConnected] = useState(false); const [error, setError] = useState(null); const socketRef = useRef(null); const approvalKeyRef = useRef(null); const trId = resolveOrderBookTrId(market); useEffect(() => { // 종목 변경 시 이전 호가 데이터 즉시 초기화 setRealtimeOrderBook(null); setError(null); if (!symbol || !isVerified || !credentials) { socketRef.current?.close(); socketRef.current = null; approvalKeyRef.current = null; return; } let disposed = false; let socket: WebSocket | null = null; const connect = async () => { try { 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; socket.onopen = () => { if (disposed || !approvalKeyRef.current) return; 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 orderbook = parseKisRealtimeOrderbook(event.data, symbol); if (!orderbook) return; orderbook.tradingEnv = credentials.tradingEnv; setRealtimeOrderBook(orderbook); }; socket.onerror = (event) => { if (disposed) return; setIsConnected(false); console.error("WebSocket Error:", event); }; socket.onclose = () => { if (disposed) return; setIsConnected(false); }; } catch (err) { if (disposed) return; setError( err instanceof Error ? err.message : "실시간 호가 소켓 초기화 중 오류가 발생했습니다.", ); setIsConnected(false); } }; void connect(); 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; }; }, [isVerified, symbol, market, credentials, trId]); return { realtimeOrderBook, isConnected, error, }; }