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

302 lines
8.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 {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
} from "@/features/dashboard/types/dashboard.types";
import {
buildKisRealtimeMessage,
parseKisRealtimeOrderbook,
parseKisRealtimeTickBatch,
} from "@/features/dashboard/utils/kis-realtime.utils";
2026-02-11 11:18:15 +09:00
import {
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
resolveDomesticKisSession,
shouldUseAfterHoursSinglePriceTr,
shouldUseExpectedExecutionTr,
type DomesticKisSession,
} from "@/lib/kis/domestic-market-session";
const TRADE_TR_ID = "H0STCNT0";
const TRADE_TR_ID_EXPECTED = "H0STANC0";
const TRADE_TR_ID_OVERTIME = "H0STOUP0";
const ORDERBOOK_TR_ID = "H0STASP0";
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0";
2026-02-10 11:16:39 +09:00
const MAX_TRADE_TICKS = 10;
2026-02-11 11:18:15 +09:00
function resolveTradeTrId(
env: KisRuntimeCredentials["tradingEnv"],
session: DomesticKisSession,
) {
2026-02-10 11:16:39 +09:00
if (env === "mock") return TRADE_TR_ID;
2026-02-11 11:18:15 +09:00
if (shouldUseAfterHoursSinglePriceTr(session)) return TRADE_TR_ID_OVERTIME;
if (shouldUseExpectedExecutionTr(session)) return TRADE_TR_ID_EXPECTED;
return TRADE_TR_ID;
2026-02-10 11:16:39 +09:00
}
2026-02-11 11:18:15 +09:00
function resolveOrderBookTrId(
env: KisRuntimeCredentials["tradingEnv"],
session: DomesticKisSession,
) {
if (env === "mock") return ORDERBOOK_TR_ID;
if (shouldUseAfterHoursSinglePriceTr(session)) return ORDERBOOK_TR_ID_OVERTIME;
return ORDERBOOK_TR_ID;
2026-02-10 11:16:39 +09:00
}
/**
2026-02-11 11:18:15 +09:00
* @description KIS / WebSocket으로 .
* @see features/dashboard/components/DashboardContainer.tsx useKisTradeWebSocket
* @see lib/kis/domestic-market-session.ts override
2026-02-10 11:16:39 +09:00
*/
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<DashboardRealtimeTradeTick | null>(null);
const [recentTradeTicks, setRecentTradeTicks] = useState<
DashboardRealtimeTradeTick[]
>([]);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
2026-02-11 11:18:15 +09:00
const [marketSession, setMarketSession] = useState<DomesticKisSession>(() =>
resolveSessionInClient(),
);
2026-02-10 11:16:39 +09:00
const socketRef = useRef<WebSocket | null>(null);
const approvalKeyRef = useRef<string | null>(null);
const seenTickRef = useRef<Set<string>>(new Set());
const obSymbol = options?.orderBookSymbol;
const onOrderBookMsg = options?.onOrderBookMessage;
2026-02-11 11:18:15 +09:00
const realtimeTrId = credentials
? resolveTradeTrId(credentials.tradingEnv, marketSession)
: TRADE_TR_ID;
// KST 장 세션을 주기적으로 재평가합니다.
useEffect(() => {
const timerId = window.setInterval(() => {
const nextSession = resolveSessionInClient();
setMarketSession((prev) => (prev === nextSession ? prev : nextSession));
}, 30_000);
return () => window.clearInterval(timerId);
}, []);
2026-02-10 11:16:39 +09:00
2026-02-11 11:18:15 +09:00
// 연결은 되었는데 체결이 오래 안 들어오는 경우 안내합니다.
2026-02-10 11:16:39 +09:00
useEffect(() => {
if (!isConnected || lastTickAt) return;
const timer = window.setTimeout(() => {
setError(
2026-02-11 11:18:15 +09:00
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간) 여부를 확인해 주세요.",
2026-02-10 11:16:39 +09:00
);
}, 8000);
return () => window.clearTimeout(timer);
}, [isConnected, lastTickAt]);
useEffect(() => {
setLatestTick(null);
setRecentTradeTicks([]);
setError(null);
2026-02-11 11:18:15 +09:00
setLastTickAt(null);
2026-02-10 11:16:39 +09:00
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;
2026-02-11 11:18:15 +09:00
const tradeTrId = resolveTradeTrId(credentials.tradingEnv, marketSession);
const orderBookTrId = obSymbol
? resolveOrderBookTrId(credentials.tradingEnv, marketSession)
: null;
2026-02-10 11:16:39 +09:00
const connect = async () => {
try {
setError(null);
setIsConnected(false);
const approvalKey = await useKisRuntimeStore
.getState()
.getOrFetchApprovalKey();
2026-02-11 11:18:15 +09:00
if (!approvalKey) {
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
}
2026-02-10 11:16:39 +09:00
2026-02-11 11:18:15 +09:00
if (disposed) return;
2026-02-10 11:16:39 +09:00
approvalKeyRef.current = approvalKey;
const wsBase =
process.env.NEXT_PUBLIC_KIS_WS_URL ||
"ws://ops.koreainvestment.com:21000";
2026-02-11 11:18:15 +09:00
socket = new WebSocket(`${wsBase}/tryitout/${tradeTrId}`);
2026-02-10 11:16:39 +09:00
socketRef.current = socket;
socket.onopen = () => {
if (disposed || !approvalKeyRef.current) return;
socket?.send(
JSON.stringify(
buildKisRealtimeMessage(
approvalKeyRef.current,
symbol,
2026-02-11 11:18:15 +09:00
tradeTrId,
2026-02-10 11:16:39 +09:00
"1",
),
),
);
2026-02-11 11:18:15 +09:00
if (obSymbol && orderBookTrId) {
2026-02-10 11:16:39 +09:00
socket?.send(
JSON.stringify(
buildKisRealtimeMessage(
approvalKeyRef.current,
obSymbol,
2026-02-11 11:18:15 +09:00
orderBookTrId,
2026-02-10 11:16:39 +09:00
"1",
),
),
);
}
setIsConnected(true);
};
socket.onmessage = (event) => {
if (disposed || typeof event.data !== "string") return;
if (obSymbol && onOrderBookMsg) {
2026-02-11 11:18:15 +09:00
const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol);
if (orderBook) {
orderBook.tradingEnv = credentials.tradingEnv;
onOrderBookMsg(orderBook);
2026-02-10 11:16:39 +09:00
return;
}
}
const ticks = parseKisRealtimeTickBatch(event.data, symbol);
if (ticks.length === 0) return;
2026-02-11 11:18:15 +09:00
const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0);
const dedupedTicks = meaningfulTicks.filter((tick) => {
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
2026-02-10 11:16:39 +09:00
if (seenTickRef.current.has(key)) return false;
seenTickRef.current.add(key);
return true;
});
const latest = ticks[ticks.length - 1];
setLatestTick(latest);
2026-02-11 11:18:15 +09:00
if (dedupedTicks.length > 0) {
2026-02-10 11:16:39 +09:00
setRecentTradeTicks((prev) =>
2026-02-11 11:18:15 +09:00
[...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
2026-02-10 11:16:39 +09:00
);
}
setError(null);
setLastTickAt(Date.now());
onTick?.(latest);
};
socket.onerror = () => {
if (!disposed) setIsConnected(false);
};
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
socket.onclose = () => {
if (!disposed) setIsConnected(false);
};
} catch (err) {
if (disposed) return;
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
setError(
err instanceof Error
? err.message
: "실시간 웹소켓 초기화 중 오류가 발생했습니다.",
);
setIsConnected(false);
}
};
void connect();
const seenRef = seenTickRef.current;
return () => {
disposed = true;
setIsConnected(false);
const key = approvalKeyRef.current;
if (socket?.readyState === WebSocket.OPEN && key) {
socket.send(
2026-02-11 11:18:15 +09:00
JSON.stringify(buildKisRealtimeMessage(key, symbol, tradeTrId, "2")),
2026-02-10 11:16:39 +09:00
);
2026-02-11 11:18:15 +09:00
if (obSymbol && orderBookTrId) {
2026-02-10 11:16:39 +09:00
socket.send(
2026-02-11 11:18:15 +09:00
JSON.stringify(
buildKisRealtimeMessage(key, obSymbol, orderBookTrId, "2"),
),
2026-02-10 11:16:39 +09:00
);
}
}
socket?.close();
if (socketRef.current === socket) socketRef.current = null;
approvalKeyRef.current = null;
seenRef.clear();
};
}, [
symbol,
2026-02-11 11:18:15 +09:00
isVerified,
2026-02-10 11:16:39 +09:00
credentials,
2026-02-11 11:18:15 +09:00
marketSession,
2026-02-10 11:16:39 +09:00
onTick,
obSymbol,
onOrderBookMsg,
]);
return {
latestTick,
recentTradeTicks,
isConnected,
error,
lastTickAt,
2026-02-11 11:18:15 +09:00
realtimeTrId,
2026-02-10 11:16:39 +09:00
};
}
2026-02-11 11:18:15 +09:00
function resolveSessionInClient() {
if (typeof window === "undefined") {
return resolveDomesticKisSession();
}
try {
const override = window.localStorage.getItem(
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
);
return resolveDomesticKisSession(override);
} catch {
return resolveDomesticKisSession();
}
}