import { useState, useRef, useEffect } from "react"; import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; 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; isVerified: boolean; credentials: KisRuntimeCredentials | null; marketSession: DomesticKisSession; onTick?: (tick: DashboardRealtimeTradeTick) => void; } /** * @description 실시간 체결가(Tick) 구독 로직을 담당하는 훅입니다. * - 웹소켓을 통해 들어오는 체결 데이터를 파싱(parsing)하고 * - 중복 데이터(deduplication)를 필터링하며 * - 최근 N개의 체결 내역(recentTradeTicks)과 최신 체결가(latestTick) 상태를 관리합니다. */ export function useTradeTickSubscription({ symbol, isVerified, credentials, marketSession, onTick, }: UseTradeTickSubscriptionParams) { const [latestTick, setLatestTick] = useState(null); const [recentTradeTicks, setRecentTradeTicks] = useState< DashboardRealtimeTradeTick[] >([]); const [lastTickAt, setLastTickAt] = useState(null); const seenTickRef = useRef>(new Set()); const activeTradeTrIdRef = useRef(null); const activeTradeTrUpdatedAtRef = useRef(0); const subscribeRef = useRef(useKisWebSocketStore.getState().subscribe); const connectRef = useRef(useKisWebSocketStore.getState().connect); const onTickRef = useRef(onTick); useEffect(() => { onTickRef.current = onTick; }, [onTick]); // 1. 심볼이 변경되면 상태를 초기화합니다. // 1. 심볼 변경 시 상태 초기화 (Render-time adjustment) const [prevSymbol, setPrevSymbol] = useState(symbol); if (symbol !== prevSymbol) { setPrevSymbol(symbol); setLatestTick(null); setRecentTradeTicks([]); setLastTickAt(null); } // Ref는 렌더링 도중 수정하면 안 되므로 useEffect에서 초기화 useEffect(() => { seenTickRef.current.clear(); activeTradeTrIdRef.current = null; activeTradeTrUpdatedAtRef.current = 0; }, [symbol]); // 2. 실시간 데이터 구독 useEffect(() => { if (!symbol || !isVerified || !credentials) return; connectRef.current(); const trIds = resolveTradeTrIds(credentials.tradingEnv, marketSession); 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 executedTicks = ticks.filter( (tick) => !tick.isExpected && tick.tradeVolume > 0, ); const expectedTicks = ticks.filter((tick) => tick.isExpected); // 시간외 예상체결은 가격 갱신용으로만 사용하고, 체결목록(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 = executedTicks[executedTicks.length - 1]; setLatestTick(latest); setLastTickAt(Date.now()); if (dedupedTicks.length > 0) { setRecentTradeTicks((prev) => [...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS), ); } onTickRef.current?.(latest); }; for (const trId of trIds) { unsubscribers.push( subscribeRef.current(trId, symbol, handleTradeMessage), ); } return () => { unsubscribers.forEach((unsub) => unsub()); }; }, [symbol, isVerified, credentials, marketSession]); return { latestTick, recentTradeTicks, lastTickAt }; }