2026-02-10 11:16:39 +09:00
|
|
|
import { useEffect, useRef, useState } from "react";
|
2026-02-11 16:31:28 +09:00
|
|
|
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";
|
2026-02-10 11:16:39 +09:00
|
|
|
import { toast } from "sonner";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 초기 REST 호가를 한 번 조회하고, 이후에는 웹소켓 호가를 우선 사용합니다.
|
2026-02-11 16:31:28 +09:00
|
|
|
* 웹소켓 호가 데이터는 TradeContainer에서 useKisTradeWebSocket을 통해
|
2026-02-10 11:16:39 +09:00
|
|
|
* 단일 WebSocket으로 수신되어 externalRealtimeOrderBook으로 주입됩니다.
|
2026-02-11 16:31:28 +09:00
|
|
|
* @see features/trade/components/TradeContainer.tsx 호가 데이터 흐름
|
|
|
|
|
* @see features/trade/components/orderbook/OrderBook.tsx 호가창 렌더링 데이터 공급
|
2026-02-10 11:16:39 +09:00
|
|
|
*/
|
|
|
|
|
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<string>("");
|
|
|
|
|
|
|
|
|
|
const [initialData, setInitialData] =
|
|
|
|
|
useState<DashboardStockOrderBookResponse | null>(null);
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
// 외부 실시간 호가 → 초기 데이터 → null 순 우선
|
|
|
|
|
const orderBook = isRequestEnabled
|
|
|
|
|
? (externalRealtimeOrderBook ?? initialData)
|
|
|
|
|
: null;
|
|
|
|
|
const mergedError = isRequestEnabled ? error : null;
|
|
|
|
|
const mergedLoading = isRequestEnabled ? isLoading && !orderBook : false;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
orderBook,
|
|
|
|
|
isLoading: mergedLoading,
|
|
|
|
|
error: mergedError,
|
|
|
|
|
isWsConnected: !!externalRealtimeOrderBook,
|
|
|
|
|
};
|
|
|
|
|
}
|