Files
auto-trade/features/dashboard/hooks/useKisOrderbookWebSocket.ts

185 lines
5.5 KiB
TypeScript
Raw Normal View History

2026-02-10 11:16:39 +09:00
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<DashboardStockOrderBookResponse | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const approvalKeyRef = useRef<string | null>(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,
};
}