import { useEffect, useMemo, useRef, useState } from "react"; import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import type { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types"; import { fetchStockOrderBook } from "@/features/trade/apis/kis-stock.api"; import { toast } from "sonner"; /** * @description 초기 REST 호가를 한 번 조회하고, 이후에는 웹소켓 호가를 우선 사용합니다. * 웹소켓 호가 데이터는 TradeContainer에서 useKisTradeWebSocket을 통해 * 단일 WebSocket으로 수신되어 externalRealtimeOrderBook으로 주입됩니다. * @see features/trade/components/TradeContainer.tsx 호가 데이터 흐름 * @see features/trade/components/orderbook/OrderBook.tsx 호가창 렌더링 데이터 공급 */ export function useOrderBook( symbol: string | undefined, market: "KOSPI" | "KOSDAQ" | undefined, credentials: KisRuntimeCredentials | null, isVerified: boolean, options: { enabled?: boolean; /** 체결 WS에서 받은 실시간 호가 데이터 (단일 WS 통합) */ externalRealtimeOrderBook?: DashboardStockOrderBookResponse | null; } = {}, ) { const { enabled = true, externalRealtimeOrderBook = null } = options; const isRequestEnabled = enabled && !!symbol && !!credentials; const requestSeqRef = useRef(0); const lastErrorToastRef = useRef(""); const [initialData, setInitialData] = useState(null); const [lastRealtimeWithLevels, setLastRealtimeWithLevels] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!isRequestEnabled) { setLastRealtimeWithLevels(null); return; } if (externalRealtimeOrderBook && hasOrderBookLevelData(externalRealtimeOrderBook)) { setLastRealtimeWithLevels(externalRealtimeOrderBook); } }, [externalRealtimeOrderBook, isRequestEnabled]); useEffect(() => { if (!isRequestEnabled || !symbol || !credentials) { return; } const requestSeq = ++requestSeqRef.current; let isDisposed = false; const loadInitialOrderBook = async () => { setInitialData(null); setIsLoading(true); setError(null); try { const data = await fetchStockOrderBook(symbol, credentials); if (isDisposed || requestSeq !== requestSeqRef.current) return; setInitialData(data); } catch (err) { if (isDisposed || requestSeq !== requestSeqRef.current) return; console.error("Failed to fetch initial orderbook:", err); const message = err instanceof Error ? err.message : "호가 정보를 불러오지 못했습니다. 잠시 후 다시 시도해주세요."; setError(message); if (lastErrorToastRef.current !== message) { lastErrorToastRef.current = message; toast.error(message); } } finally { if (isDisposed || requestSeq !== requestSeqRef.current) return; setIsLoading(false); } }; void loadInitialOrderBook(); return () => { isDisposed = true; }; }, [isRequestEnabled, symbol, credentials]); // 외부 실시간 호가가 비어 있으면(가격/잔량 레벨 0) 마지막 정상 실시간 또는 REST 초기값을 유지합니다. const orderBook = useMemo(() => { if (!isRequestEnabled) return null; if (externalRealtimeOrderBook) { if (hasOrderBookLevelData(externalRealtimeOrderBook)) { return externalRealtimeOrderBook; } if (lastRealtimeWithLevels) { return { ...lastRealtimeWithLevels, totalAskSize: externalRealtimeOrderBook.totalAskSize, totalBidSize: externalRealtimeOrderBook.totalBidSize, anticipatedPrice: externalRealtimeOrderBook.anticipatedPrice, anticipatedVolume: externalRealtimeOrderBook.anticipatedVolume, anticipatedTotalVolume: externalRealtimeOrderBook.anticipatedTotalVolume, anticipatedChange: externalRealtimeOrderBook.anticipatedChange, anticipatedChangeSign: externalRealtimeOrderBook.anticipatedChangeSign, anticipatedChangeRate: externalRealtimeOrderBook.anticipatedChangeRate, accumulatedVolume: externalRealtimeOrderBook.accumulatedVolume, totalAskSizeDelta: externalRealtimeOrderBook.totalAskSizeDelta, totalBidSizeDelta: externalRealtimeOrderBook.totalBidSizeDelta, businessHour: externalRealtimeOrderBook.businessHour ?? lastRealtimeWithLevels.businessHour, hourClassCode: externalRealtimeOrderBook.hourClassCode ?? lastRealtimeWithLevels.hourClassCode, fetchedAt: externalRealtimeOrderBook.fetchedAt, tradingEnv: externalRealtimeOrderBook.tradingEnv, source: externalRealtimeOrderBook.source, }; } if (initialData && hasOrderBookLevelData(initialData)) { return { ...initialData, totalAskSize: externalRealtimeOrderBook.totalAskSize, totalBidSize: externalRealtimeOrderBook.totalBidSize, anticipatedPrice: externalRealtimeOrderBook.anticipatedPrice, anticipatedVolume: externalRealtimeOrderBook.anticipatedVolume, anticipatedTotalVolume: externalRealtimeOrderBook.anticipatedTotalVolume, anticipatedChange: externalRealtimeOrderBook.anticipatedChange, anticipatedChangeSign: externalRealtimeOrderBook.anticipatedChangeSign, anticipatedChangeRate: externalRealtimeOrderBook.anticipatedChangeRate, accumulatedVolume: externalRealtimeOrderBook.accumulatedVolume, totalAskSizeDelta: externalRealtimeOrderBook.totalAskSizeDelta, totalBidSizeDelta: externalRealtimeOrderBook.totalBidSizeDelta, businessHour: externalRealtimeOrderBook.businessHour ?? initialData.businessHour, hourClassCode: externalRealtimeOrderBook.hourClassCode ?? initialData.hourClassCode, fetchedAt: externalRealtimeOrderBook.fetchedAt, tradingEnv: externalRealtimeOrderBook.tradingEnv, source: externalRealtimeOrderBook.source, }; } return externalRealtimeOrderBook; } return initialData; }, [externalRealtimeOrderBook, initialData, isRequestEnabled, lastRealtimeWithLevels]); const mergedError = isRequestEnabled ? error : null; const mergedLoading = isRequestEnabled ? isLoading && !orderBook : false; return { orderBook, isLoading: mergedLoading, error: mergedError, isWsConnected: !!externalRealtimeOrderBook, }; } function hasOrderBookLevelData(orderBook: DashboardStockOrderBookResponse) { return orderBook.levels.some( (level) => level.askPrice > 0 || level.bidPrice > 0 || level.askSize > 0 || level.bidSize > 0, ); }