284 lines
8.6 KiB
TypeScript
284 lines
8.6 KiB
TypeScript
|
|
import { useEffect, useRef, useState } from "react";
|
||
|
|
import {
|
||
|
|
type KisRuntimeCredentials,
|
||
|
|
useKisRuntimeStore,
|
||
|
|
} from "@/features/dashboard/store/use-kis-runtime-store";
|
||
|
|
import type {
|
||
|
|
DashboardRealtimeTradeTick,
|
||
|
|
StockCandlePoint,
|
||
|
|
} from "@/features/dashboard/types/dashboard.types";
|
||
|
|
import {
|
||
|
|
appendRealtimeTick,
|
||
|
|
buildKisRealtimeMessage,
|
||
|
|
parseKisRealtimeTickBatch,
|
||
|
|
toTickOrderValue,
|
||
|
|
} from "@/features/dashboard/utils/kis-realtime.utils";
|
||
|
|
|
||
|
|
const KIS_REALTIME_TR_ID_REAL = "H0STCNT0";
|
||
|
|
const KIS_REALTIME_TR_ID_OVERTIME = "H0STOUP0"; // 시간외 단일가
|
||
|
|
const KIS_REALTIME_TR_ID_MOCK = "H0STCNT0";
|
||
|
|
|
||
|
|
const MAX_TRADE_TICKS = 10; // 체결 테이프용 최대 개수
|
||
|
|
|
||
|
|
function resolveRealtimeTrId(tradingEnv: KisRuntimeCredentials["tradingEnv"]) {
|
||
|
|
if (tradingEnv === "mock") return KIS_REALTIME_TR_ID_MOCK;
|
||
|
|
|
||
|
|
// 현재 시간(KST) 체크 - 사용자 브라우저 시간 기준
|
||
|
|
const now = new Date();
|
||
|
|
const hours = now.getHours();
|
||
|
|
const minutes = now.getMinutes();
|
||
|
|
const time = hours * 100 + minutes;
|
||
|
|
|
||
|
|
// 16:00 ~ 18:00 사이는 시간외 단일가 TR ID 사용
|
||
|
|
if (time >= 1600 && time < 1800) {
|
||
|
|
return KIS_REALTIME_TR_ID_OVERTIME;
|
||
|
|
}
|
||
|
|
|
||
|
|
return KIS_REALTIME_TR_ID_REAL;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @description 통합 실시간 체결 웹소켓 훅 (H0STCNT0)
|
||
|
|
* - StockHeader: 실시간 현재가, 등락률, 시가/고가/저가/거래량
|
||
|
|
* - StockLineChart: 실시간 캔들 (분봉/일봉 등)
|
||
|
|
* - OrderBook (TradeTape): 최근 체결 내역 리스트
|
||
|
|
*/
|
||
|
|
export function useKisTradeWebSocket(
|
||
|
|
symbol: string | undefined,
|
||
|
|
credentials: KisRuntimeCredentials | null,
|
||
|
|
isVerified: boolean,
|
||
|
|
onTick?: (tick: DashboardRealtimeTradeTick) => void,
|
||
|
|
) {
|
||
|
|
// 1. StockHeader용 최신 데이터
|
||
|
|
const [latestTick, setLatestTick] =
|
||
|
|
useState<DashboardRealtimeTradeTick | null>(null);
|
||
|
|
|
||
|
|
// 2. StockLineChart용 캔들 데이터
|
||
|
|
const [realtimeCandles, setRealtimeCandles] = useState<StockCandlePoint[]>(
|
||
|
|
[],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 3. TradeTape용 최근 체결 리스트
|
||
|
|
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 realtimeTrId = credentials
|
||
|
|
? resolveRealtimeTrId(credentials.tradingEnv)
|
||
|
|
: null;
|
||
|
|
|
||
|
|
// 데이터 없음 감지 (8초)
|
||
|
|
useEffect(() => {
|
||
|
|
if (!isConnected || lastTickAt) return;
|
||
|
|
|
||
|
|
const noTickTimer = window.setTimeout(() => {
|
||
|
|
setError(
|
||
|
|
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요.",
|
||
|
|
);
|
||
|
|
}, 8000);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
window.clearTimeout(noTickTimer);
|
||
|
|
};
|
||
|
|
}, [isConnected, lastTickAt]);
|
||
|
|
|
||
|
|
// 웹소켓 연결 로직
|
||
|
|
useEffect(() => {
|
||
|
|
// 초기화
|
||
|
|
setLatestTick(null);
|
||
|
|
setRealtimeCandles([]);
|
||
|
|
setRecentTradeTicks([]);
|
||
|
|
setError(null);
|
||
|
|
seenTickRef.current.clear();
|
||
|
|
|
||
|
|
if (!symbol || !isVerified || !credentials) {
|
||
|
|
if (socketRef.current) {
|
||
|
|
socketRef.current.close();
|
||
|
|
socketRef.current = null;
|
||
|
|
}
|
||
|
|
approvalKeyRef.current = null;
|
||
|
|
setIsConnected(false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let disposed = false;
|
||
|
|
let socket: WebSocket | null = null;
|
||
|
|
const trId = resolveRealtimeTrId(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 wsBaseUrl =
|
||
|
|
process.env.NEXT_PUBLIC_KIS_WS_URL ||
|
||
|
|
"ws://ops.koreainvestment.com:21000";
|
||
|
|
socket = new WebSocket(`${wsBaseUrl}/tryitout/${trId}`);
|
||
|
|
socketRef.current = socket;
|
||
|
|
|
||
|
|
console.log("[WS URL]", `${wsBaseUrl}/tryitout/${trId}`);
|
||
|
|
|
||
|
|
socket.onopen = () => {
|
||
|
|
if (disposed || !approvalKeyRef.current) return;
|
||
|
|
console.log("[WS Open] Connected");
|
||
|
|
|
||
|
|
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 parsedTicks = parseKisRealtimeTickBatch(event.data, symbol);
|
||
|
|
|
||
|
|
if (parsedTicks.length === 0) {
|
||
|
|
console.log(
|
||
|
|
"[WS Parsed] No ticks found. Check TrId/Symbol match.",
|
||
|
|
{ symbol, trId },
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 1. 데이터 정제 및 중복 제거 (TradeTape용)
|
||
|
|
const meaningfulTicks = parsedTicks.filter(
|
||
|
|
(tick) => tick.tradeVolume > 0,
|
||
|
|
);
|
||
|
|
const dedupedForTape = meaningfulTicks.filter((tick) => {
|
||
|
|
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
|
||
|
|
if (seenTickRef.current.has(key)) return false;
|
||
|
|
seenTickRef.current.add(key);
|
||
|
|
return true;
|
||
|
|
});
|
||
|
|
|
||
|
|
// 2. 최신 틱 업데이트 (StockHeader용)
|
||
|
|
// 배치 중 가장 마지막(최신) 틱을 사용
|
||
|
|
const lastTickInBatch = parsedTicks[parsedTicks.length - 1];
|
||
|
|
setLatestTick(lastTickInBatch);
|
||
|
|
|
||
|
|
// 3. 캔들 업데이트 (StockLineChart용)
|
||
|
|
// 지연 도착 틱 필터링
|
||
|
|
const nextTickOrder = toTickOrderValue(lastTickInBatch.tickTime);
|
||
|
|
if (nextTickOrder > 0) {
|
||
|
|
if (lastTickOrderRef.current <= nextTickOrder) {
|
||
|
|
lastTickOrderRef.current = nextTickOrder;
|
||
|
|
const candlePoint: StockCandlePoint = {
|
||
|
|
time: formatTime(lastTickInBatch.tickTime),
|
||
|
|
price: lastTickInBatch.price,
|
||
|
|
// 필요한 경우 open, high, low, volume 등을 여기서 조합 가능
|
||
|
|
// 현재 차트 컴포넌트는 Point 단위로 price만 주로 씀
|
||
|
|
};
|
||
|
|
setRealtimeCandles((prev) =>
|
||
|
|
appendRealtimeTick(prev, candlePoint),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 4. 체결 테이프 업데이트
|
||
|
|
if (dedupedForTape.length > 0) {
|
||
|
|
setRecentTradeTicks((prev) =>
|
||
|
|
[...dedupedForTape.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 5. 콜백 및 상태 업데이트
|
||
|
|
setError(null);
|
||
|
|
setLastTickAt(Date.now());
|
||
|
|
|
||
|
|
// onTick 콜백용 데이터 구성
|
||
|
|
if (onTick) {
|
||
|
|
onTick(lastTickInBatch);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
socket.onerror = () => {
|
||
|
|
if (disposed) return;
|
||
|
|
setIsConnected(false);
|
||
|
|
// setError("실시간 체결 연결 중 오류가 발생했습니다.");
|
||
|
|
};
|
||
|
|
|
||
|
|
socket.onclose = () => {
|
||
|
|
if (disposed) return;
|
||
|
|
setIsConnected(false);
|
||
|
|
};
|
||
|
|
} catch (err) {
|
||
|
|
if (disposed) return;
|
||
|
|
const message =
|
||
|
|
err instanceof Error
|
||
|
|
? err.message
|
||
|
|
: "실시간 웹소켓 초기화 중 오류가 발생했습니다.";
|
||
|
|
setError(message);
|
||
|
|
setIsConnected(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
void connect();
|
||
|
|
|
||
|
|
const seenTickRefCurrent = seenTickRef.current;
|
||
|
|
|
||
|
|
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;
|
||
|
|
seenTickRefCurrent.clear();
|
||
|
|
};
|
||
|
|
}, [isVerified, symbol, credentials, onTick]);
|
||
|
|
|
||
|
|
return {
|
||
|
|
latestTick, // Header용
|
||
|
|
realtimeCandles, // Chart용
|
||
|
|
recentTradeTicks, // Tape용
|
||
|
|
isConnected,
|
||
|
|
error,
|
||
|
|
lastTickAt,
|
||
|
|
realtimeTrId: realtimeTrId ?? KIS_REALTIME_TR_ID_REAL,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatTime(hhmmss: string) {
|
||
|
|
if (!hhmmss || hhmmss.length !== 6) return "실시간";
|
||
|
|
return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`;
|
||
|
|
}
|