292 lines
8.9 KiB
TypeScript
292 lines
8.9 KiB
TypeScript
|
|
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<DashboardRealtimeTradeTick | null>(null);
|
||
|
|
const [realtimeCandles, setRealtimeCandles] = useState<StockCandlePoint[]>(
|
||
|
|
[],
|
||
|
|
);
|
||
|
|
const [recentTradeTicks, setRecentTradeTicks] = useState<
|
||
|
|
DashboardRealtimeTradeTick[]
|
||
|
|
>([]);
|
||
|
|
const [isConnected, setIsConnected] = useState(false);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
|
||
|
|
|
||
|
|
const socketRef = useRef<WebSocket | null>(null);
|
||
|
|
const approvalKeyRef = useRef<string | null>(null);
|
||
|
|
const lastTickOrderRef = useRef<number>(-1);
|
||
|
|
const seenTickRef = useRef<Set<string>>(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,
|
||
|
|
};
|
||
|
|
}
|