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 { parseKisRealtimeTickBatch, resolveTradeTrIds, } from "@/features/trade/utils/kisRealtimeUtils"; import type { DomesticKisSession } from "@/lib/kis/domestic-market-session"; const MAX_TRADE_TICKS = 10; 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 { subscribe, connect } = useKisWebSocketStore(); 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(); }, [symbol]); // 2. 실시간 데이터 구독 useEffect(() => { if (!symbol || !isVerified || !credentials) return; connect(); const trIds = resolveTradeTrIds(credentials.tradingEnv, marketSession); const unsubscribers: Array<() => void> = []; const handleTradeMessage = (data: string) => { const ticks = parseKisRealtimeTickBatch(data, symbol); if (ticks.length === 0) return; const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0); if (meaningfulTicks.length === 0) 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 = meaningfulTicks[meaningfulTicks.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(subscribe(trId, symbol, handleTradeMessage)); } return () => { unsubscribers.forEach((unsub) => unsub()); }; }, [symbol, isVerified, credentials, marketSession, connect, subscribe]); return { latestTick, recentTradeTicks, lastTickAt }; }