Files
auto-trade/features/trade/hooks/useOrderBook.ts

172 lines
6.8 KiB
TypeScript

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<string>("");
const [initialData, setInitialData] =
useState<DashboardStockOrderBookResponse | null>(null);
const [lastRealtimeWithLevels, setLastRealtimeWithLevels] =
useState<DashboardStockOrderBookResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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,
);
}