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

489 lines
14 KiB
TypeScript
Raw Normal View History

2026-02-10 11:16:39 +09:00
import { useEffect, useRef, useState } from "react";
import {
type KisRuntimeCredentials,
useKisRuntimeStore,
2026-02-11 16:31:28 +09:00
} from "@/features/settings/store/use-kis-runtime-store";
2026-02-10 11:16:39 +09:00
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
2026-02-11 16:31:28 +09:00
} from "@/features/trade/types/trade.types";
2026-02-10 11:16:39 +09:00
import {
buildKisRealtimeMessage,
parseKisRealtimeOrderbook,
parseKisRealtimeTickBatch,
2026-02-11 16:31:28 +09:00
} from "@/features/trade/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";
2026-02-12 10:24:03 +09:00
const TRADE_TR_ID_OVERTIME_EXPECTED = "H0STOAC0";
const TRADE_TR_ID_TOTAL = "H0UNCNT0";
const TRADE_TR_ID_TOTAL_EXPECTED = "H0UNANC0";
2026-02-11 11:18:15 +09:00
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-12 10:24:03 +09:00
const WS_DEBUG_STORAGE_KEY = "KIS_WS_DEBUG";
2026-02-10 11:16:39 +09:00
2026-02-12 10:24:03 +09:00
/**
* @description / TR ID를 .
* @see features/trade/hooks/useKisTradeWebSocket.ts useEffect WebSocket
* @see temp-kis-domestic-functions-ws.py ccnl_krx/ccnl_total/exp_ccnl_krx/exp_ccnl_total/overtime_ccnl_krx
*/
function resolveTradeTrIds(
2026-02-11 11:18:15 +09:00
env: KisRuntimeCredentials["tradingEnv"],
session: DomesticKisSession,
) {
2026-02-12 10:24:03 +09:00
if (env === "mock") return [TRADE_TR_ID];
if (shouldUseAfterHoursSinglePriceTr(session)) {
// 시간외 단일가(16:00~18:00): 전용 TR + 통합 TR 백업
return uniqueTrIds([
TRADE_TR_ID_OVERTIME,
TRADE_TR_ID_OVERTIME_EXPECTED,
TRADE_TR_ID_TOTAL,
TRADE_TR_ID_TOTAL_EXPECTED,
]);
}
if (shouldUseExpectedExecutionTr(session)) {
// 동시호가 구간(장전/장마감): 예상체결 TR을 우선, 일반체결/통합체결을 백업
return uniqueTrIds([
TRADE_TR_ID_EXPECTED,
TRADE_TR_ID_TOTAL_EXPECTED,
TRADE_TR_ID,
TRADE_TR_ID_TOTAL,
]);
}
if (session === "afterCloseFixedPrice") {
// 시간외 종가(15:40~16:00): 브로커별 라우팅 차이를 대비해 일반/시간외/통합 TR을 함께 구독
return uniqueTrIds([
TRADE_TR_ID,
TRADE_TR_ID_TOTAL,
TRADE_TR_ID_OVERTIME,
TRADE_TR_ID_OVERTIME_EXPECTED,
TRADE_TR_ID_TOTAL_EXPECTED,
]);
}
return uniqueTrIds([TRADE_TR_ID, TRADE_TR_ID_TOTAL]);
2026-02-10 11:16:39 +09:00
}
2026-02-12 10:24:03 +09:00
/**
* @description TR ID .
* @see features/trade/hooks/useKisTradeWebSocket.ts useEffect WebSocket
* @see temp-kis-domestic-functions-ws.py asking_price_krx/asking_price_total/overtime_asking_price_krx
*/
function resolveOrderBookTrIds(
2026-02-11 11:18:15 +09:00
env: KisRuntimeCredentials["tradingEnv"],
session: DomesticKisSession,
) {
2026-02-12 10:24:03 +09:00
if (env === "mock") return [ORDERBOOK_TR_ID];
if (shouldUseAfterHoursSinglePriceTr(session)) {
// 시간외 단일가(16:00~18:00)는 KRX 전용 호가 TR만 구독합니다.
// 통합 TR(H0UNASP0)을 같이 구독하면 종목별로 포맷/잔량이 섞여 보일 수 있습니다.
return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME]);
}
if (session === "afterCloseFixedPrice") {
return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME]);
}
// UI 흐름: 호가창 UI -> useKisTradeWebSocket onmessage -> onOrderBookMessage
// -> TradeContainer setRealtimeOrderBook -> useOrderBook 병합 -> OrderBook 렌더
// 장중에는 KRX 전용(H0STASP0)만 구독해 값이 번갈아 덮이는 현상을 방지합니다.
return uniqueTrIds([ORDERBOOK_TR_ID]);
}
/**
* @description .
* @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage
*/
function isWsDebugEnabled() {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(WS_DEBUG_STORAGE_KEY) === "1";
} catch {
return false;
}
}
/**
* @description (JSON) .
* @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage
*/
function parseWsControlMessage(raw: string) {
if (!raw.startsWith("{")) return null;
try {
return JSON.parse(raw) as {
header?: { tr_id?: string };
body?: { rt_cd?: string; msg1?: string };
};
} catch {
return null;
}
}
/**
* @description TR ID를 .
* @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage
*/
function peekPipeTrId(raw: string) {
const parts = raw.split("|");
return parts.length > 1 ? parts[1] : "";
}
function uniqueTrIds(ids: string[]) {
return [...new Set(ids)];
2026-02-10 11:16:39 +09:00
}
/**
2026-02-11 15:27:03 +09:00
* @description Subscribes trade ticks and orderbook over one websocket.
2026-02-11 16:31:28 +09:00
* @see features/trade/components/TradeContainer.tsx
2026-02-11 15:27:03 +09:00
* @see lib/kis/domestic-market-session.ts
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-12 10:24:03 +09:00
const realtimeTrIds = credentials
? resolveTradeTrIds(credentials.tradingEnv, marketSession)
: [TRADE_TR_ID];
2026-02-11 11:18:15 +09:00
const realtimeTrId = credentials
2026-02-12 10:24:03 +09:00
? realtimeTrIds[0] ?? TRADE_TR_ID
2026-02-11 11:18:15 +09:00
: TRADE_TR_ID;
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
useEffect(() => {
if (!isConnected || lastTickAt) return;
const timer = window.setTimeout(() => {
setError(
2026-02-11 15:27:03 +09:00
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간)과 TR ID를 확인해 주세요.",
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-12 10:24:03 +09:00
const debugEnabled = isWsDebugEnabled();
const tradeTrIds = resolveTradeTrIds(credentials.tradingEnv, marketSession);
const orderBookTrIds =
obSymbol && onOrderBookMsg
? resolveOrderBookTrIds(credentials.tradingEnv, marketSession)
: [];
const subscribe = (
key: string,
targetSymbol: string,
trId: string,
trType: "1" | "2",
) => {
socket?.send(
JSON.stringify(buildKisRealtimeMessage(key, targetSymbol, trId, trType)),
);
};
2026-02-10 11:16:39 +09:00
const connect = async () => {
try {
setError(null);
setIsConnected(false);
2026-02-11 15:27:03 +09:00
const wsConnection = await useKisRuntimeStore
2026-02-10 11:16:39 +09:00
.getState()
2026-02-11 15:27:03 +09:00
.getOrFetchWsConnection();
2026-02-10 11:16:39 +09:00
2026-02-11 15:27:03 +09:00
if (!wsConnection) {
2026-02-11 11:18:15 +09:00
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
}
2026-02-10 11:16:39 +09:00
2026-02-11 11:18:15 +09:00
if (disposed) return;
2026-02-11 15:27:03 +09:00
approvalKeyRef.current = wsConnection.approvalKey;
2026-02-10 11:16:39 +09:00
2026-02-12 10:24:03 +09:00
// 공식 샘플과 동일하게 /tryitout 엔드포인트로 연결하고, TR은 payload로 구독합니다.
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout`);
2026-02-10 11:16:39 +09:00
socketRef.current = socket;
socket.onopen = () => {
if (disposed || !approvalKeyRef.current) return;
2026-02-12 10:24:03 +09:00
for (const trId of tradeTrIds) {
subscribe(approvalKeyRef.current, symbol, trId, "1");
}
if (obSymbol) {
for (const trId of orderBookTrIds) {
subscribe(approvalKeyRef.current, obSymbol, trId, "1");
}
}
if (debugEnabled) {
console.info("[KisRealtime] Subscribed", {
symbol,
marketSession,
tradeTrIds,
orderBookSymbol: obSymbol ?? null,
orderBookTrIds,
});
2026-02-10 11:16:39 +09:00
}
setIsConnected(true);
};
socket.onmessage = (event) => {
if (disposed || typeof event.data !== "string") return;
2026-02-12 10:24:03 +09:00
const control = parseWsControlMessage(event.data);
if (control) {
const trId = control.header?.tr_id ?? "";
if (trId === "PINGPONG") {
// 서버 Keepalive에 응답하지 않으면 연결이 끊길 수 있습니다.
socket?.send(event.data);
return;
}
if (debugEnabled) {
console.info("[KisRealtime] Control", {
trId,
rt_cd: control.body?.rt_cd,
message: control.body?.msg1,
});
}
return;
}
2026-02-10 11:16:39 +09:00
if (obSymbol && onOrderBookMsg) {
2026-02-11 11:18:15 +09:00
const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol);
if (orderBook) {
orderBook.tradingEnv = credentials.tradingEnv;
2026-02-12 10:24:03 +09:00
if (debugEnabled) {
console.debug("[KisRealtime] OrderBook", {
trId: peekPipeTrId(event.data),
symbol: orderBook.symbol,
businessHour: orderBook.businessHour,
hourClassCode: orderBook.hourClassCode,
});
}
2026-02-11 11:18:15 +09:00
onOrderBookMsg(orderBook);
2026-02-10 11:16:39 +09:00
return;
}
}
const ticks = parseKisRealtimeTickBatch(event.data, symbol);
2026-02-12 10:24:03 +09:00
if (ticks.length === 0) {
if (debugEnabled && event.data.includes("|")) {
console.debug("[KisRealtime] Unparsed payload", {
trId: peekPipeTrId(event.data),
preview: event.data.slice(0, 220),
});
}
return;
}
2026-02-10 11:16:39 +09:00
2026-02-11 11:18:15 +09:00
const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0);
2026-02-12 10:24:03 +09:00
if (meaningfulTicks.length === 0) {
if (debugEnabled) {
console.debug("[KisRealtime] Ignored zero-volume ticks", {
trId: peekPipeTrId(event.data),
parsedCount: ticks.length,
});
}
return;
}
2026-02-11 11:18:15 +09:00
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);
2026-02-12 10:24:03 +09:00
if (seenTickRef.current.size > 5_000) {
seenTickRef.current.clear();
}
2026-02-10 11:16:39 +09:00
return true;
});
2026-02-12 10:24:03 +09:00
const latest = meaningfulTicks[meaningfulTicks.length - 1];
2026-02-10 11:16:39 +09:00
setLatestTick(latest);
2026-02-12 10:24:03 +09:00
if (debugEnabled) {
console.debug("[KisRealtime] Tick", {
trId: peekPipeTrId(event.data),
symbol: latest.symbol,
tickTime: latest.tickTime,
price: latest.price,
tradeVolume: latest.tradeVolume,
executionClassCode: latest.executionClassCode,
buyExecutionCount: latest.buyExecutionCount,
sellExecutionCount: latest.sellExecutionCount,
netBuyExecutionCount: latest.netBuyExecutionCount,
parsedCount: ticks.length,
});
}
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 = () => {
2026-02-12 10:24:03 +09:00
if (!disposed) {
if (debugEnabled) {
console.warn("[KisRealtime] WebSocket error", {
symbol,
marketSession,
tradeTrIds,
});
}
setIsConnected(false);
}
2026-02-10 11:16:39 +09:00
};
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
socket.onclose = () => {
2026-02-12 10:24:03 +09:00
if (!disposed) {
if (debugEnabled) {
console.warn("[KisRealtime] WebSocket closed", {
symbol,
marketSession,
tradeTrIds,
});
}
setIsConnected(false);
}
2026-02-10 11:16:39 +09:00
};
} 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) {
2026-02-12 10:24:03 +09:00
for (const trId of tradeTrIds) {
subscribe(key, symbol, trId, "2");
}
2026-02-11 11:18:15 +09:00
2026-02-12 10:24:03 +09:00
if (obSymbol) {
for (const trId of orderBookTrIds) {
subscribe(key, obSymbol, trId, "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();
}
}