정리
This commit is contained in:
@@ -23,28 +23,128 @@ import {
|
||||
const TRADE_TR_ID = "H0STCNT0";
|
||||
const TRADE_TR_ID_EXPECTED = "H0STANC0";
|
||||
const TRADE_TR_ID_OVERTIME = "H0STOUP0";
|
||||
const TRADE_TR_ID_OVERTIME_EXPECTED = "H0STOAC0";
|
||||
const TRADE_TR_ID_TOTAL = "H0UNCNT0";
|
||||
const TRADE_TR_ID_TOTAL_EXPECTED = "H0UNANC0";
|
||||
const ORDERBOOK_TR_ID = "H0STASP0";
|
||||
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0";
|
||||
const MAX_TRADE_TICKS = 10;
|
||||
const WS_DEBUG_STORAGE_KEY = "KIS_WS_DEBUG";
|
||||
|
||||
function resolveTradeTrId(
|
||||
/**
|
||||
* @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(
|
||||
env: KisRuntimeCredentials["tradingEnv"],
|
||||
session: DomesticKisSession,
|
||||
) {
|
||||
if (env === "mock") return TRADE_TR_ID;
|
||||
if (shouldUseAfterHoursSinglePriceTr(session)) return TRADE_TR_ID;
|
||||
if (shouldUseExpectedExecutionTr(session)) return TRADE_TR_ID;
|
||||
return TRADE_TR_ID;
|
||||
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]);
|
||||
}
|
||||
|
||||
function resolveOrderBookTrId(
|
||||
/**
|
||||
* @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(
|
||||
env: KisRuntimeCredentials["tradingEnv"],
|
||||
session: DomesticKisSession,
|
||||
) {
|
||||
if (env === "mock") return ORDERBOOK_TR_ID;
|
||||
if (shouldUseAfterHoursSinglePriceTr(session))
|
||||
return ORDERBOOK_TR_ID_OVERTIME;
|
||||
return ORDERBOOK_TR_ID;
|
||||
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)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,8 +180,11 @@ export function useKisTradeWebSocket(
|
||||
|
||||
const obSymbol = options?.orderBookSymbol;
|
||||
const onOrderBookMsg = options?.onOrderBookMessage;
|
||||
const realtimeTrIds = credentials
|
||||
? resolveTradeTrIds(credentials.tradingEnv, marketSession)
|
||||
: [TRADE_TR_ID];
|
||||
const realtimeTrId = credentials
|
||||
? resolveTradeTrId(credentials.tradingEnv, marketSession)
|
||||
? realtimeTrIds[0] ?? TRADE_TR_ID
|
||||
: TRADE_TR_ID;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -122,11 +225,24 @@ export function useKisTradeWebSocket(
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
const debugEnabled = isWsDebugEnabled();
|
||||
|
||||
const tradeTrId = resolveTradeTrId(credentials.tradingEnv, marketSession);
|
||||
const orderBookTrId = obSymbol
|
||||
? resolveOrderBookTrId(credentials.tradingEnv, marketSession)
|
||||
: null;
|
||||
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)),
|
||||
);
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
@@ -144,34 +260,31 @@ export function useKisTradeWebSocket(
|
||||
if (disposed) return;
|
||||
approvalKeyRef.current = wsConnection.approvalKey;
|
||||
|
||||
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout/${tradeTrId}`);
|
||||
// 공식 샘플과 동일하게 /tryitout 엔드포인트로 연결하고, TR은 payload로 구독합니다.
|
||||
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (disposed || !approvalKeyRef.current) return;
|
||||
|
||||
socket?.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
symbol,
|
||||
tradeTrId,
|
||||
"1",
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const trId of tradeTrIds) {
|
||||
subscribe(approvalKeyRef.current, symbol, trId, "1");
|
||||
}
|
||||
|
||||
if (obSymbol && orderBookTrId) {
|
||||
socket?.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
obSymbol,
|
||||
orderBookTrId,
|
||||
"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,
|
||||
});
|
||||
}
|
||||
|
||||
setIsConnected(true);
|
||||
@@ -180,29 +293,92 @@ export function useKisTradeWebSocket(
|
||||
socket.onmessage = (event) => {
|
||||
if (disposed || typeof event.data !== "string") return;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (obSymbol && onOrderBookMsg) {
|
||||
const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol);
|
||||
if (orderBook) {
|
||||
orderBook.tradingEnv = credentials.tradingEnv;
|
||||
if (debugEnabled) {
|
||||
console.debug("[KisRealtime] OrderBook", {
|
||||
trId: peekPipeTrId(event.data),
|
||||
symbol: orderBook.symbol,
|
||||
businessHour: orderBook.businessHour,
|
||||
hourClassCode: orderBook.hourClassCode,
|
||||
});
|
||||
}
|
||||
onOrderBookMsg(orderBook);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ticks = parseKisRealtimeTickBatch(event.data, symbol);
|
||||
if (ticks.length === 0) return;
|
||||
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;
|
||||
}
|
||||
|
||||
const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0);
|
||||
if (meaningfulTicks.length === 0) {
|
||||
if (debugEnabled) {
|
||||
console.debug("[KisRealtime] Ignored zero-volume ticks", {
|
||||
trId: peekPipeTrId(event.data),
|
||||
parsedCount: ticks.length,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dedupedTicks = meaningfulTicks.filter((tick) => {
|
||||
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
|
||||
if (seenTickRef.current.has(key)) return false;
|
||||
seenTickRef.current.add(key);
|
||||
if (seenTickRef.current.size > 5_000) {
|
||||
seenTickRef.current.clear();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const latest = ticks[ticks.length - 1];
|
||||
const latest = meaningfulTicks[meaningfulTicks.length - 1];
|
||||
setLatestTick(latest);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
if (dedupedTicks.length > 0) {
|
||||
setRecentTradeTicks((prev) =>
|
||||
[...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
|
||||
@@ -215,11 +391,29 @@ export function useKisTradeWebSocket(
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
if (!disposed) setIsConnected(false);
|
||||
if (!disposed) {
|
||||
if (debugEnabled) {
|
||||
console.warn("[KisRealtime] WebSocket error", {
|
||||
symbol,
|
||||
marketSession,
|
||||
tradeTrIds,
|
||||
});
|
||||
}
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (!disposed) setIsConnected(false);
|
||||
if (!disposed) {
|
||||
if (debugEnabled) {
|
||||
console.warn("[KisRealtime] WebSocket closed", {
|
||||
symbol,
|
||||
marketSession,
|
||||
tradeTrIds,
|
||||
});
|
||||
}
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
@@ -242,16 +436,14 @@ export function useKisTradeWebSocket(
|
||||
|
||||
const key = approvalKeyRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN && key) {
|
||||
socket.send(
|
||||
JSON.stringify(buildKisRealtimeMessage(key, symbol, tradeTrId, "2")),
|
||||
);
|
||||
for (const trId of tradeTrIds) {
|
||||
subscribe(key, symbol, trId, "2");
|
||||
}
|
||||
|
||||
if (obSymbol && orderBookTrId) {
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(key, obSymbol, orderBookTrId, "2"),
|
||||
),
|
||||
);
|
||||
if (obSymbol) {
|
||||
for (const trId of orderBookTrIds) {
|
||||
subscribe(key, obSymbol, trId, "2");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user