From 19ebb1c6eadfca7332fb848650e25dab67355a28 Mon Sep 17 00:00:00 2001 From: "jihoon87.lee" Date: Mon, 23 Feb 2026 15:37:22 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=9B=B9?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kis-realtime/stores/kisWebSocketStore.ts | 189 +++++++++++++++--- features/trade/components/TradeContainer.tsx | 1 + .../layout/TradeDashboardContent.tsx | 1 - .../trade/components/orderbook/OrderBook.tsx | 171 ++++++++++++---- features/trade/hooks/useCurrentPrice.ts | 11 +- features/trade/hooks/useKisTradeWebSocket.ts | 2 + .../trade/hooks/useOrderbookSubscription.ts | 36 +++- .../trade/hooks/useTradeTickSubscription.ts | 60 +++++- features/trade/types/trade.types.ts | 2 + features/trade/utils/kisRealtimeUtils.ts | 133 ++++++++++-- 10 files changed, 510 insertions(+), 96 deletions(-) diff --git a/features/kis-realtime/stores/kisWebSocketStore.ts b/features/kis-realtime/stores/kisWebSocketStore.ts index cfe6cc0..c7c2a44 100644 --- a/features/kis-realtime/stores/kisWebSocketStore.ts +++ b/features/kis-realtime/stores/kisWebSocketStore.ts @@ -52,10 +52,16 @@ const subscribers = new Map>(); const subscriberCounts = new Map(); // 실제 소켓 구독 요청 여부 추적용 let socket: WebSocket | null = null; -let pingInterval: number | undefined; let isConnecting = false; // 연결 진행 중 상태 잠금 let reconnectRetryTimer: number | undefined; let lastAppKeyConflictAt = 0; +let reconnectAttempt = 0; +let manualDisconnectRequested = false; + +const MAX_AUTO_RECONNECT_ATTEMPTS = 8; +const RECONNECT_BASE_DELAY_MS = 1_000; +const RECONNECT_MAX_DELAY_MS = 30_000; +const RECONNECT_JITTER_MS = 300; export const useKisWebSocketStore = create((set, get) => ({ isConnected: false, @@ -63,6 +69,9 @@ export const useKisWebSocketStore = create((set, get) => ({ connect: async (options) => { const forceApprovalRefresh = options?.forceApprovalRefresh ?? false; + manualDisconnectRequested = false; + window.clearTimeout(reconnectRetryTimer); + reconnectRetryTimer = undefined; const currentSocket = socket; if (currentSocket?.readyState === WebSocket.CLOSING) { @@ -70,12 +79,7 @@ export const useKisWebSocketStore = create((set, get) => ({ } // 1. 이미 연결되어 있거나, 연결 시도 중이면 중복 실행 방지 - if ( - socket?.readyState === WebSocket.OPEN || - socket?.readyState === WebSocket.CONNECTING || - socket?.readyState === WebSocket.CLOSING || - isConnecting - ) { + if (isSocketUnavailableForNewConnect(socket) || isConnecting) { return; } @@ -89,10 +93,7 @@ export const useKisWebSocketStore = create((set, get) => ({ const wsConnection = await getOrFetchWsConnection(); // 비동기 대기 중에 다른 연결이 성사되었는지 다시 확인 - if ( - socket?.readyState === WebSocket.OPEN || - socket?.readyState === WebSocket.CONNECTING - ) { + if (isSocketOpenOrConnecting(socket)) { isConnecting = false; return; } @@ -113,6 +114,7 @@ export const useKisWebSocketStore = create((set, get) => ({ if (socket !== ws) return; set({ isConnected: true, error: null }); + reconnectAttempt = 0; console.log("[KisWebSocket] Connected"); // 재연결 시 기존 구독 복구 @@ -127,23 +129,46 @@ export const useKisWebSocketStore = create((set, get) => ({ } }); } - - // PINGPONG (Keep-alive) - window.clearInterval(pingInterval); - pingInterval = window.setInterval(() => { - if (socket?.readyState === WebSocket.OPEN) { - socket.send("PINGPONG"); // 일부 환경에서는 PINGPONG 텍스트 전송 - } - }, 100_000); // 100초 주기 }; - ws.onclose = () => { + ws.onclose = (event) => { if (socket === ws) { isConnecting = false; set({ isConnected: false }); - console.log("[KisWebSocket] Disconnected"); - window.clearInterval(pingInterval); socket = null; + + const hasSubscribers = hasActiveRealtimeSubscribers(); + const canAutoReconnect = + !manualDisconnectRequested && + hasSubscribers && + reconnectAttempt < MAX_AUTO_RECONNECT_ATTEMPTS; + + if (canAutoReconnect) { + reconnectAttempt += 1; + const delayMs = getReconnectDelayMs(reconnectAttempt); + console.warn( + `[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"}) -> reconnect in ${delayMs}ms (attempt ${reconnectAttempt}/${MAX_AUTO_RECONNECT_ATTEMPTS})`, + ); + + window.clearTimeout(reconnectRetryTimer); + reconnectRetryTimer = window.setTimeout(() => { + const refreshApproval = reconnectAttempt % 3 === 0; + void get().reconnect({ refreshApproval }); + }, delayMs); + return; + } + + if (hasSubscribers && reconnectAttempt >= MAX_AUTO_RECONNECT_ATTEMPTS) { + set({ + error: + "실시간 연결이 반복 종료되어 자동 재연결을 중단했습니다. 새로고침 또는 수동 재연결을 시도해 주세요.", + }); + } + + reconnectAttempt = 0; + console.log( + `[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"})`, + ); } }; @@ -165,7 +190,17 @@ export const useKisWebSocketStore = create((set, get) => ({ // PINGPONG 응답 또는 제어 메시지 처리 if (data.startsWith("{")) { const control = parseControlMessage(data); - if (control?.rt_cd && control.rt_cd !== "0") { + if (!control) return; + + if (control.trId === "PINGPONG") { + // KIS 샘플 구현과 동일하게 원문을 그대로 echo하여 연결 유지를 보조합니다. + if (socket === ws && ws.readyState === WebSocket.OPEN) { + ws.send(data); + } + return; + } + + if (control.rtCd && control.rtCd !== "0") { const errorMessage = buildControlErrorMessage(control); set({ error: errorMessage, @@ -173,7 +208,7 @@ export const useKisWebSocketStore = create((set, get) => ({ // KIS 제어 메시지: ALREADY IN USE appkey // 이전 세션이 닫히기 전에 재연결될 때 간헐적으로 발생합니다. - if (control.msg_cd === "OPSP8996") { + if (control.msgCd === "OPSP8996") { const now = Date.now(); if (now - lastAppKeyConflictAt > 5_000) { lastAppKeyConflictAt = now; @@ -183,6 +218,14 @@ export const useKisWebSocketStore = create((set, get) => ({ }, 1_200); } } + + // 승인키가 유효하지 않을 때는 승인키 재발급 후 재연결합니다. + if (control.msgCd === "OPSP0011") { + window.clearTimeout(reconnectRetryTimer); + reconnectRetryTimer = window.setTimeout(() => { + void get().reconnect({ refreshApproval: true }); + }, 1_200); + } } return; } @@ -212,6 +255,7 @@ export const useKisWebSocketStore = create((set, get) => ({ reconnect: async (options) => { const refreshApproval = options?.refreshApproval ?? false; + manualDisconnectRequested = false; const currentSocket = socket; get().disconnect(); if (currentSocket && currentSocket.readyState !== WebSocket.CLOSED) { @@ -223,6 +267,7 @@ export const useKisWebSocketStore = create((set, get) => ({ }, disconnect: () => { + manualDisconnectRequested = true; const currentSocket = socket; if ( currentSocket && @@ -236,8 +281,9 @@ export const useKisWebSocketStore = create((set, get) => ({ socket = null; } set({ isConnected: false }); - window.clearInterval(pingInterval); window.clearTimeout(reconnectRetryTimer); + reconnectRetryTimer = undefined; + reconnectAttempt = 0; isConnecting = false; }, @@ -310,9 +356,12 @@ function sendSubscription( } interface KisWsControlMessage { - rt_cd?: string; - msg_cd?: string; + trId?: string; + trKey?: string; + rtCd?: string; + msgCd?: string; msg1?: string; + encrypt?: string; } /** @@ -323,8 +372,32 @@ interface KisWsControlMessage { */ function parseControlMessage(rawData: string): KisWsControlMessage | null { try { - const parsed = JSON.parse(rawData) as KisWsControlMessage; - return parsed && typeof parsed === "object" ? parsed : null; + const parsed = JSON.parse(rawData) as { + header?: { + tr_id?: string; + tr_key?: string; + encrypt?: string; + }; + body?: { + rt_cd?: string; + msg_cd?: string; + msg1?: string; + }; + rt_cd?: string; + msg_cd?: string; + msg1?: string; + }; + + if (!parsed || typeof parsed !== "object") return null; + + return { + trId: parsed.header?.tr_id, + trKey: parsed.header?.tr_key, + encrypt: parsed.header?.encrypt, + rtCd: parsed.body?.rt_cd ?? parsed.rt_cd, + msgCd: parsed.body?.msg_cd ?? parsed.msg_cd, + msg1: parsed.body?.msg1 ?? parsed.msg1, + }; } catch { return null; } @@ -337,13 +410,67 @@ function parseControlMessage(rawData: string): KisWsControlMessage | null { * @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage */ function buildControlErrorMessage(message: KisWsControlMessage) { - if (message.msg_cd === "OPSP8996") { + if (message.msgCd === "OPSP8996") { return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다."; } - const detail = [message.msg1, message.msg_cd].filter(Boolean).join(" / "); + const detail = [message.msg1, message.msgCd].filter(Boolean).join(" / "); return detail ? `실시간 제어 메시지 오류: ${detail}` : "실시간 제어 메시지 오류"; } +/** + * @description 활성화된 웹소켓 구독이 존재하는지 반환합니다. + * @returns 구독 중인 TR/심볼이 1개 이상이면 true + * @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onclose + */ +function hasActiveRealtimeSubscribers() { + for (const count of subscriberCounts.values()) { + if (count > 0) return true; + } + return false; +} + +/** + * @description 자동 재연결 시도 횟수에 따라 지수 백오프 지연시간(ms)을 계산합니다. + * @param attempt 1부터 시작하는 재연결 시도 횟수 + * @returns 지연시간(ms) + * @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onclose + */ +function getReconnectDelayMs(attempt: number) { + const exponential = RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1); + const clamped = Math.min(exponential, RECONNECT_MAX_DELAY_MS); + const jitter = Math.floor(Math.random() * RECONNECT_JITTER_MS); + return clamped + jitter; +} + +/** + * @description 소켓이 OPEN 또는 CONNECTING 상태인지 검사합니다. + * @param target 검사 대상 소켓 + * @returns 연결 유지/진행 상태면 true + * @see features/kis-realtime/stores/kisWebSocketStore.ts connect + */ +function isSocketOpenOrConnecting(target: WebSocket | null) { + if (!target) return false; + return ( + target.readyState === WebSocket.OPEN || + target.readyState === WebSocket.CONNECTING + ); +} + +/** + * @description 새 연결을 시작하면 안 되는 소켓 상태인지 검사합니다. + * @param target 검사 대상 소켓 + * @returns OPEN/CONNECTING/CLOSING 중 하나면 true + * @see features/kis-realtime/stores/kisWebSocketStore.ts connect + */ +function isSocketUnavailableForNewConnect(target: WebSocket | null) { + if (!target) return false; + return ( + target.readyState === WebSocket.OPEN || + target.readyState === WebSocket.CONNECTING || + target.readyState === WebSocket.CLOSING + ); +} + /** * @description 특정 웹소켓 인스턴스가 완전히 닫힐 때까지 대기합니다. * @param target 대기할 웹소켓 인스턴스 diff --git a/features/trade/components/TradeContainer.tsx b/features/trade/components/TradeContainer.tsx index bf25779..1407a98 100644 --- a/features/trade/components/TradeContainer.tsx +++ b/features/trade/components/TradeContainer.tsx @@ -148,6 +148,7 @@ export function TradeContainer() { updateRealtimeTradeTick, { orderBookSymbol: selectedStock?.symbol, + orderBookMarket: selectedStock?.market, onOrderBookMessage: handleOrderBookMessage, }, ); diff --git a/features/trade/components/layout/TradeDashboardContent.tsx b/features/trade/components/layout/TradeDashboardContent.tsx index 89a95c9..9e8d45d 100644 --- a/features/trade/components/layout/TradeDashboardContent.tsx +++ b/features/trade/components/layout/TradeDashboardContent.tsx @@ -89,7 +89,6 @@ export function TradeDashboardContent({ 0 ? ((price - base) / base) * 100 : 0; } +/** + * @description 기준가 대비 증감값/증감률을 함께 계산합니다. + * @see features/trade/components/orderbook/OrderBook.tsx buildBookRows + */ +function resolvePriceChange(price: number, basePrice: number) { + if (price <= 0 || basePrice <= 0) { + return { changeValue: null } as const; + } + + const changeValue = price - basePrice; + + return { changeValue } as const; +} + +/** + * @description 증감 숫자를 부호 포함 문자열로 포맷합니다. + * @see features/trade/components/orderbook/OrderBook.tsx BookSideRows + */ +function fmtSignedChange(v: number) { + if (!Number.isFinite(v)) return "-"; + if (v > 0) return `+${fmt(v)}`; + if (v < 0) return `-${fmt(Math.abs(v))}`; + return "0"; +} + +/** + * @description 증감값에 따라 색상 톤 클래스를 반환합니다. + * @see features/trade/components/orderbook/OrderBook.tsx BookSideRows + */ +function getChangeToneClass( + changeValue: number | null, + neutralClass = "text-muted-foreground", +) { + if (changeValue === null) { + return neutralClass; + } + if (changeValue > 0) { + return "text-red-500"; + } + if (changeValue < 0) { + return "text-blue-600 dark:text-blue-400"; + } + return neutralClass; +} + /** 체결 시각 포맷 */ function fmtTime(hms: string) { if (!hms || hms.length !== 6) return "--:--:--"; @@ -131,6 +175,65 @@ function resolveTickExecutionSide( return "neutral" as const; } +/** + * @description 호가 레벨을 화면 렌더링용 행 모델로 변환합니다. + * UI 흐름: OrderBook(levels/basePrice/latestPrice) -> buildBookRows -> BookSideRows -> 가격/증감 반영 + * @see features/trade/components/orderbook/OrderBook.tsx OrderBook askRows/bidRows 계산 + */ +function buildBookRows({ + levels, + side, + basePrice, + latestPrice, +}: { + levels: DashboardStockOrderBookResponse["levels"]; + side: "ask" | "bid"; + basePrice: number; + latestPrice: number; +}) { + const normalizedLevels = side === "ask" ? [...levels].reverse() : levels; + + return normalizedLevels.map((level) => { + const price = side === "ask" ? level.askPrice : level.bidPrice; + const size = side === "ask" ? level.askSize : level.bidSize; + const { changeValue } = resolvePriceChange(price, basePrice); + + return { + price, + size: Math.max(size, 0), + changeValue, + isHighlighted: latestPrice > 0 && price === latestPrice, + } satisfies BookRow; + }); +} + +/** + * @description 호가/체결 증감 표시용 기준가(전일종가)를 결정합니다. + * @summary UI 흐름: OrderBook props -> resolveReferencePrice -> 호가행/체결가 증감 계산 반영 + * @see features/trade/components/orderbook/OrderBook.tsx basePrice 계산 + */ +function resolveReferencePrice({ + referencePrice, + latestTick, +}: { + referencePrice?: number; + latestTick: DashboardRealtimeTradeTick | null; +}) { + if ((referencePrice ?? 0) > 0) { + return referencePrice!; + } + + // referencePrice 미전달 케이스에서도 틱 데이터(price-change)로 전일종가를 역산합니다. + if (latestTick?.price && Number.isFinite(latestTick.change)) { + const derivedPrevClose = latestTick.price - latestTick.change; + if (derivedPrevClose > 0) { + return derivedPrevClose; + } + } + + return 0; +} + // ─── 메인 컴포넌트 ────────────────────────────────────── /** @@ -139,7 +242,6 @@ function resolveTickExecutionSide( export function OrderBook({ symbol, referencePrice, - currentPrice, latestTick, recentTicks, orderBook, @@ -162,42 +264,29 @@ export function OrderBook({ latestTick?.price && latestTick.price > 0 ? latestTick.price : 0; // 등락률 기준가 - const basePrice = - (referencePrice ?? 0) > 0 - ? referencePrice! - : (currentPrice ?? 0) > 0 - ? currentPrice! - : latestPrice > 0 - ? latestPrice - : 0; + const basePrice = resolveReferencePrice({ referencePrice, latestTick }); // 매도호가 (역순: 10호가 → 1호가) const askRows: BookRow[] = useMemo( () => - [...levels].reverse().map((l) => ({ - price: l.askPrice, - size: Math.max(l.askSize, 0), - changePercent: - l.askPrice > 0 && basePrice > 0 - ? pctChange(l.askPrice, basePrice) - : null, - isHighlighted: latestPrice > 0 && l.askPrice === latestPrice, - })), + buildBookRows({ + levels, + side: "ask", + basePrice, + latestPrice, + }), [levels, basePrice, latestPrice], ); // 매수호가 (1호가 → 10호가) const bidRows: BookRow[] = useMemo( () => - levels.map((l) => ({ - price: l.bidPrice, - size: Math.max(l.bidSize, 0), - changePercent: - l.bidPrice > 0 && basePrice > 0 - ? pctChange(l.bidPrice, basePrice) - : null, - isHighlighted: latestPrice > 0 && l.bidPrice === latestPrice, - })), + buildBookRows({ + levels, + side: "bid", + basePrice, + latestPrice, + }), [levels, basePrice, latestPrice], ); @@ -439,15 +528,11 @@ function BookSideRows({ = 0 - ? "text-red-500" - : "text-blue-600 dark:text-blue-400" - : "text-muted-foreground", + "w-[58px] shrink-0 text-right text-[10px] tabular-nums", + getChangeToneClass(row.changeValue), )} > - {row.changePercent === null ? "-" : fmtPct(row.changePercent)} + {row.changeValue === null ? "-" : fmtSignedChange(row.changeValue)} @@ -493,6 +578,11 @@ function SummaryPanel({ totalAsk: number; totalBid: number; }) { + const displayTradeVolume = + latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0 + ? (orderBook?.anticipatedVolume ?? 0) + : (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0); + return (
{fmtTime(t.tickTime)}
-
+
{fmt(t.price)}
0) { currentPrice = latestTick.price; - change = latestTick.change; - changeRate = latestTick.changeRate; + // 실시간 틱(change/changeRate)은 TR 종류(실체결/예상체결)에 따라 해석 차이가 있을 수 있어 + // 화면 표시는 항상 전일종가(prevClose) 기준으로 재계산해 일관성을 유지합니다. + if (prevClose > 0) { + change = currentPrice - prevClose; + changeRate = (change / prevClose) * 100; + } else { + change = latestTick.change; + changeRate = latestTick.changeRate; + } } // 2. Fallback: OrderBook Best Ask (Proxy for current price if no tick) else if ( diff --git a/features/trade/hooks/useKisTradeWebSocket.ts b/features/trade/hooks/useKisTradeWebSocket.ts index 90753a8..7ac38f8 100644 --- a/features/trade/hooks/useKisTradeWebSocket.ts +++ b/features/trade/hooks/useKisTradeWebSocket.ts @@ -27,6 +27,7 @@ export function useKisTradeWebSocket( onTick?: (tick: DashboardRealtimeTradeTick) => void, options?: { orderBookSymbol?: string; + orderBookMarket?: "KOSPI" | "KOSDAQ"; onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void; }, ) { @@ -45,6 +46,7 @@ export function useKisTradeWebSocket( useOrderbookSubscription({ symbol: options?.orderBookSymbol, + market: options?.orderBookMarket, isVerified, credentials, marketSession, diff --git a/features/trade/hooks/useOrderbookSubscription.ts b/features/trade/hooks/useOrderbookSubscription.ts index 3a76399..235b227 100644 --- a/features/trade/hooks/useOrderbookSubscription.ts +++ b/features/trade/hooks/useOrderbookSubscription.ts @@ -3,18 +3,23 @@ import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-ru import type { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types"; import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; import { + extractKisRealtimeTrId, + hasMeaningfulOrderBookPayload, parseKisRealtimeOrderbook, resolveOrderBookTrIds, + shouldAcceptRealtimeMessageByPriority, } from "@/features/trade/utils/kisRealtimeUtils"; import type { DomesticKisSession } from "@/lib/kis/domestic-market-session"; interface UseOrderbookSubscriptionParams { symbol: string | undefined; // orderBookSymbol + market: "KOSPI" | "KOSDAQ" | undefined; isVerified: boolean; credentials: KisRuntimeCredentials | null; marketSession: DomesticKisSession; onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void; } +const STABLE_SOURCE_STALE_MS = Number.POSITIVE_INFINITY; /** * @description 실시간 호가(Orderbook) 구독 로직을 담당하는 훅입니다. @@ -24,6 +29,7 @@ interface UseOrderbookSubscriptionParams { */ export function useOrderbookSubscription({ symbol, + market, isVerified, credentials, marketSession, @@ -31,6 +37,8 @@ export function useOrderbookSubscription({ }: UseOrderbookSubscriptionParams) { const { subscribe, connect } = useKisWebSocketStore(); const onOrderBookMessageRef = useRef(onOrderBookMessage); + const activeOrderBookTrIdRef = useRef(null); + const activeOrderBookTrUpdatedAtRef = useRef(0); useEffect(() => { onOrderBookMessageRef.current = onOrderBookMessage; @@ -41,12 +49,34 @@ export function useOrderbookSubscription({ connect(); - const trIds = resolveOrderBookTrIds(credentials.tradingEnv, marketSession); + const trIds = resolveOrderBookTrIds( + credentials.tradingEnv, + marketSession, + market, + ); const unsubscribers: Array<() => void> = []; const handleOrderBookMessage = (data: string) => { + const incomingTrId = extractKisRealtimeTrId(data); + if (!incomingTrId) return; + + // UI 흐름: 소켓 수신 -> TR 우선순위 고정(시장별 상위 소스 우선) -> 파싱 -> 호가 상태 반영 + const shouldAccept = shouldAcceptRealtimeMessageByPriority({ + incomingTrId, + preferredTrIds: trIds, + activeTrId: activeOrderBookTrIdRef.current, + activeTrUpdatedAtMs: activeOrderBookTrUpdatedAtRef.current, + // 정규장/동시호가에서는 한 번 잡은 상위 소스를 유지해 소스 간 왕복 반영을 막습니다. + staleAfterMs: STABLE_SOURCE_STALE_MS, + }); + if (!shouldAccept) return; + const ob = parseKisRealtimeOrderbook(data, symbol); if (ob) { + if (hasMeaningfulOrderBookPayload(ob)) { + activeOrderBookTrIdRef.current = incomingTrId; + activeOrderBookTrUpdatedAtRef.current = Date.now(); + } ob.tradingEnv = credentials.tradingEnv; onOrderBookMessageRef.current?.(ob); } @@ -58,6 +88,8 @@ export function useOrderbookSubscription({ return () => { unsubscribers.forEach((unsub) => unsub()); + activeOrderBookTrIdRef.current = null; + activeOrderBookTrUpdatedAtRef.current = 0; }; - }, [symbol, isVerified, credentials, marketSession, connect, subscribe]); + }, [symbol, market, isVerified, credentials, marketSession, connect, subscribe]); } diff --git a/features/trade/hooks/useTradeTickSubscription.ts b/features/trade/hooks/useTradeTickSubscription.ts index cb01d5c..ade740a 100644 --- a/features/trade/hooks/useTradeTickSubscription.ts +++ b/features/trade/hooks/useTradeTickSubscription.ts @@ -3,12 +3,16 @@ import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-ru import type { DashboardRealtimeTradeTick } from "@/features/trade/types/trade.types"; import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; import { + extractKisRealtimeTrId, parseKisRealtimeTickBatch, resolveTradeTrIds, + shouldAcceptRealtimeMessageByPriority, } from "@/features/trade/utils/kisRealtimeUtils"; import type { DomesticKisSession } from "@/lib/kis/domestic-market-session"; const MAX_TRADE_TICKS = 10; +const STABLE_SOURCE_STALE_MS = Number.POSITIVE_INFINITY; +const FLEXIBLE_SOURCE_STALE_MS = 3_000; interface UseTradeTickSubscriptionParams { symbol: string | undefined; @@ -38,6 +42,8 @@ export function useTradeTickSubscription({ >([]); const [lastTickAt, setLastTickAt] = useState(null); const seenTickRef = useRef>(new Set()); + const activeTradeTrIdRef = useRef(null); + const activeTradeTrUpdatedAtRef = useRef(0); const { subscribe, connect } = useKisWebSocketStore(); const onTickRef = useRef(onTick); @@ -59,6 +65,8 @@ export function useTradeTickSubscription({ // Ref는 렌더링 도중 수정하면 안 되므로 useEffect에서 초기화 useEffect(() => { seenTickRef.current.clear(); + activeTradeTrIdRef.current = null; + activeTradeTrUpdatedAtRef.current = 0; }, [symbol]); // 2. 실시간 데이터 구독 @@ -71,21 +79,63 @@ export function useTradeTickSubscription({ const unsubscribers: Array<() => void> = []; const handleTradeMessage = (data: string) => { + const incomingTrId = extractKisRealtimeTrId(data); + if (!incomingTrId) return; + + // UI 흐름: 소켓 수신 -> TR 우선순위 고정(ST 우선) -> 파싱 -> 상태 반영 + const shouldAccept = shouldAcceptRealtimeMessageByPriority({ + incomingTrId, + preferredTrIds: trIds, + activeTrId: activeTradeTrIdRef.current, + activeTrUpdatedAtMs: activeTradeTrUpdatedAtRef.current, + // 장중에는 상위 소스를 고정하고, 시간외에서는 상위 소스가 잠잠할 때 하위(예상체결)로 폴백 허용 + staleAfterMs: + marketSession === "regular" || + marketSession === "openAuction" || + marketSession === "closeAuction" + ? STABLE_SOURCE_STALE_MS + : FLEXIBLE_SOURCE_STALE_MS, + }); + if (!shouldAccept) return; + const ticks = parseKisRealtimeTickBatch(data, symbol); if (ticks.length === 0) return; - const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0); - if (meaningfulTicks.length === 0) return; + const executedTicks = ticks.filter( + (tick) => !tick.isExpected && tick.tradeVolume > 0, + ); + const expectedTicks = ticks.filter((tick) => tick.isExpected); - const dedupedTicks = meaningfulTicks.filter((tick) => { - const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`; + // 시간외 예상체결은 가격 갱신용으로만 사용하고, 체결목록(recentTradeTicks)에는 넣지 않습니다. + // UI 흐름: 소켓 수신 -> 예상체결 파싱 -> latestTick 갱신 -> 헤더/중앙가 반영 + if (executedTicks.length === 0 && expectedTicks.length > 0) { + const latestExpected = expectedTicks[expectedTicks.length - 1]; + const expectedForDisplay = { + ...latestExpected, + tradeVolume: 0, + }; + activeTradeTrIdRef.current = incomingTrId; + activeTradeTrUpdatedAtRef.current = Date.now(); + setLatestTick(expectedForDisplay); + setLastTickAt(Date.now()); + onTickRef.current?.(expectedForDisplay); + return; + } + + if (executedTicks.length === 0) return; + + activeTradeTrIdRef.current = incomingTrId; + activeTradeTrUpdatedAtRef.current = Date.now(); + + const dedupedTicks = executedTicks.filter((tick) => { + const key = `exe-${incomingTrId}-${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 = meaningfulTicks[meaningfulTicks.length - 1]; + const latest = executedTicks[executedTicks.length - 1]; setLatestTick(latest); setLastTickAt(Date.now()); diff --git a/features/trade/types/trade.types.ts b/features/trade/types/trade.types.ts index f018949..f550c57 100644 --- a/features/trade/types/trade.types.ts +++ b/features/trade/types/trade.types.ts @@ -141,6 +141,8 @@ export interface DashboardStockOrderBookResponse { */ export interface DashboardRealtimeTradeTick { symbol: string; + trId?: string; + isExpected?: boolean; tickTime: string; price: number; change: number; diff --git a/features/trade/utils/kisRealtimeUtils.ts b/features/trade/utils/kisRealtimeUtils.ts index d59515c..cbfc12b 100644 --- a/features/trade/utils/kisRealtimeUtils.ts +++ b/features/trade/utils/kisRealtimeUtils.ts @@ -56,6 +56,18 @@ const TRADE_TR_ID_TOTAL_EXPECTED = "H0UNANC0"; const ORDERBOOK_TR_ID = "H0STASP0"; const ORDERBOOK_TR_ID_TOTAL = "H0UNASP0"; const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0"; +const DEFAULT_REALTIME_TR_STALE_MS = 3_000; +const OVERTIME_ORDERBOOK_MIN_FIELDS_9 = 52; +const OVERTIME_ORDERBOOK_MIN_FIELDS_10 = 56; + +interface RealtimeTrPriorityDecisionParams { + incomingTrId: string; + preferredTrIds: string[]; + activeTrId: string | null; + activeTrUpdatedAtMs: number; + nowMs?: number; + staleAfterMs?: number; +} export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) { if (!/^([01])\|/.test(raw)) return [] as DashboardRealtimeTradeTick[]; @@ -66,7 +78,10 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) { const isExecutedTick = EXECUTED_REALTIME_TRADE_TR_IDS.has(receivedTrId); const isExpectedTick = EXPECTED_REALTIME_TRADE_TR_IDS.has(receivedTrId); - if (!isExecutedTick || isExpectedTick) { + // 정규 체결(TR)뿐 아니라 시간외/동시호가 예상체결(TR)도 틱 화면에 반영합니다. + // UI 흐름: useTradeTickSubscription -> resolveTradeTrIds(세션별 TR) -> parseKisRealtimeTickBatch + // 시간외 구간(예: H0STOAC0 only 수신)에서 틱이 비는 문제를 방지합니다. + if (!isExecutedTick && !isExpectedTick) { return [] as DashboardRealtimeTradeTick[]; } @@ -110,6 +125,8 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) { ticks.push({ symbol: normalizedExpected, + trId: receivedTrId, + isExpected: isExpectedTick, tickTime: readString(values, base + TICK_FIELD_INDEX.tickTime), price, change, @@ -197,6 +214,7 @@ export function resolveTradeTrIds( export function resolveOrderBookTrIds( env: "real" | "mock", session: DomesticKisSession, + market?: "KOSPI" | "KOSDAQ", ) { if (env === "mock") return [ORDERBOOK_TR_ID]; @@ -218,9 +236,60 @@ export function resolveOrderBookTrIds( ]); } + // 통합장(통합호가) 값이 체결앱과 더 잘 맞는 케이스가 있어 + // KOSPI는 통합(H0UNASP0) 우선, KOSDAQ은 KRX(H0STASP0) 우선으로 둡니다. + if (market === "KOSPI") { + return uniqueTrIds([ORDERBOOK_TR_ID_TOTAL, ORDERBOOK_TR_ID]); + } + return uniqueTrIds([ORDERBOOK_TR_ID, ORDERBOOK_TR_ID_TOTAL]); } +/** + * @description 웹소켓 원문에서 TR ID만 빠르게 추출합니다. + * @param raw KIS 웹소켓 원문 + * @returns TR ID. 포맷이 다르면 null + * @see features/trade/hooks/useTradeTickSubscription.ts handleTradeMessage 소스 우선순위 필터 + * @see features/trade/hooks/useOrderbookSubscription.ts handleOrderBookMessage 소스 우선순위 필터 + */ +export function extractKisRealtimeTrId(raw: string) { + if (!/^([01])\|/.test(raw)) return null; + const parts = raw.split("|", 3); + const trId = parts[1]?.trim(); + return trId ? trId : null; +} + +/** + * @description 다중 TR 구독 시 우선순위/유휴시간 기반으로 현재 메시지를 반영할지 결정합니다. + * @remarks 높은 우선순위 TR(ST 계열)은 즉시 승격하고, 현재 소스가 일정 시간 멈췄을 때만 하위 TR(UN 계열)로 폴백합니다. + * @see features/trade/hooks/useTradeTickSubscription.ts handleTradeMessage 체결 소스 고정 로직 + * @see features/trade/hooks/useOrderbookSubscription.ts handleOrderBookMessage 호가 소스 고정 로직 + */ +export function shouldAcceptRealtimeMessageByPriority({ + incomingTrId, + preferredTrIds, + activeTrId, + activeTrUpdatedAtMs, + nowMs = Date.now(), + staleAfterMs = DEFAULT_REALTIME_TR_STALE_MS, +}: RealtimeTrPriorityDecisionParams) { + const incomingPriority = preferredTrIds.indexOf(incomingTrId); + if (incomingPriority < 0) return false; + + if (!activeTrId) return true; + if (incomingTrId === activeTrId) return true; + + const activePriority = preferredTrIds.indexOf(activeTrId); + if (activePriority < 0) return true; + + if (incomingPriority < activePriority) { + return true; + } + + const isActiveStale = nowMs - activeTrUpdatedAtMs > staleAfterMs; + return isActiveStale; +} + /** * @description 호가 패킷이 실제 표시 가능한 값(호가/잔량/총잔량)을 포함하는지 확인합니다. */ @@ -270,20 +339,31 @@ export function parseKisRealtimeOrderbook( // 시간외(H0STOAA0)는 문서 버전에 따라 9호가/10호가가 혼재할 수 있어 // 두 형식을 모두 시도한 뒤 의미 있는 데이터 점수가 더 높은 결과를 선택합니다. if (trId === "H0STOAA0") { - const parsedByNineLevels = parseOrderBookByLevelCount( - values, - normalizedExpected, - 9, - ); - const parsedByTenLevels = parseOrderBookByLevelCount( - values, - normalizedExpected, - 10, - ); + const candidates: DashboardStockOrderBookResponse[] = []; + + // 길이가 충분하면 10호가를 우선 시도합니다. + if (values.length >= OVERTIME_ORDERBOOK_MIN_FIELDS_10) { + const parsedByTenLevels = parseOrderBookByLevelCount( + values, + normalizedExpected, + 10, + ); + if (parsedByTenLevels) { + candidates.push(parsedByTenLevels); + } + } + + if (values.length >= OVERTIME_ORDERBOOK_MIN_FIELDS_9) { + const parsedByNineLevels = parseOrderBookByLevelCount( + values, + normalizedExpected, + 9, + ); + if (parsedByNineLevels) { + candidates.push(parsedByNineLevels); + } + } - const candidates = [parsedByNineLevels, parsedByTenLevels].filter( - (item): item is DashboardStockOrderBookResponse => item !== null, - ); if (candidates.length === 0) return null; return pickBestOrderBookPayload(candidates); @@ -411,11 +491,30 @@ function scoreOrderBookPayload(payload: DashboardStockOrderBookResponse) { level.bidSize > 0, ).length; - return ( + let score = nonZeroLevels * 10 + (payload.totalAskSize > 0 ? 4 : 0) + (payload.totalBidSize > 0 ? 4 : 0) + ((payload.anticipatedPrice ?? 0) > 0 ? 2 : 0) + - ((payload.accumulatedVolume ?? 0) > 0 ? 1 : 0) - ); + ((payload.accumulatedVolume ?? 0) > 0 ? 1 : 0); + + // 시간외 9/10호가 파서 후보 중 잘못 정렬된 결과를 배제하기 위한 가격 밴드 검증입니다. + // (예: anticipatedPrice가 총잔량값으로 잘못 파싱되는 케이스) + const ladderPrices = payload.levels.flatMap((level) => [ + level.askPrice, + level.bidPrice, + ]); + const positivePrices = ladderPrices.filter((price) => price > 0); + const anticipatedPrice = payload.anticipatedPrice ?? 0; + + if (anticipatedPrice > 0 && positivePrices.length > 0) { + const minPrice = Math.min(...positivePrices); + const maxPrice = Math.max(...positivePrices); + const isInReasonableBand = + anticipatedPrice >= minPrice * 0.7 && anticipatedPrice <= maxPrice * 1.3; + + score += isInReasonableBand ? 12 : -80; + } + + return score; }