359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import { fetchDashboardBalance } from "@/features/dashboard/apis/dashboard.api";
|
|
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
|
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
|
import { TradeAccessGate } from "@/features/trade/components/guards/TradeAccessGate";
|
|
import { TradeDashboardContent } from "@/features/trade/components/layout/TradeDashboardContent";
|
|
import { TradeSearchSection } from "@/features/trade/components/search/TradeSearchSection";
|
|
import { AutotradeControlPanel } from "@/features/autotrade/components/AutotradeControlPanel";
|
|
import { useStockSearch } from "@/features/trade/hooks/useStockSearch";
|
|
import { useOrderBook } from "@/features/trade/hooks/useOrderBook";
|
|
import { useKisTradeWebSocket } from "@/features/trade/hooks/useKisTradeWebSocket";
|
|
import { useStockOverview } from "@/features/trade/hooks/useStockOverview";
|
|
import { useCurrentPrice } from "@/features/trade/hooks/useCurrentPrice";
|
|
import { useTradeSearchPanel } from "@/features/trade/hooks/useTradeSearchPanel";
|
|
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
|
|
import type {
|
|
DashboardStockOrderBookResponse,
|
|
DashboardStockSearchItem,
|
|
} from "@/features/trade/types/trade.types";
|
|
|
|
/**
|
|
* @description 트레이딩 페이지 메인 컨테이너입니다.
|
|
* @see app/(main)/trade/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다.
|
|
* @see app/(main)/settings/page.tsx 미인증 상태일 때 설정 페이지로 이동하도록 안내합니다.
|
|
* @see features/trade/hooks/useStockSearch.ts 검색 입력/요청/히스토리 상태를 관리합니다.
|
|
* @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
|
|
*/
|
|
export function TradeContainer() {
|
|
const router = useRouter();
|
|
const consumePendingTarget = useTradeNavigationStore(
|
|
(state) => state.consumePendingTarget,
|
|
);
|
|
|
|
// [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신)
|
|
const [realtimeOrderBook, setRealtimeOrderBook] =
|
|
useState<DashboardStockOrderBookResponse | null>(null);
|
|
// [State] 선택 종목과 매칭할 보유 종목 목록
|
|
const [holdings, setHoldings] = useState<DashboardHoldingItem[]>([]);
|
|
// [State] 주문 패널에서 사용할 가용 예수금 스냅샷(원)
|
|
const [availableCashBalance, setAvailableCashBalance] = useState<number | null>(null);
|
|
const { verifiedCredentials, isKisVerified, _hasHydrated } =
|
|
useKisRuntimeStore(
|
|
useShallow((state) => ({
|
|
verifiedCredentials: state.verifiedCredentials,
|
|
isKisVerified: state.isKisVerified,
|
|
_hasHydrated: state._hasHydrated,
|
|
})),
|
|
);
|
|
|
|
const {
|
|
keyword,
|
|
setKeyword,
|
|
searchResults,
|
|
setSearchError,
|
|
isSearching,
|
|
search,
|
|
clearSearch,
|
|
searchHistory,
|
|
appendSearchHistory,
|
|
removeSearchHistory,
|
|
clearSearchHistory,
|
|
} = useStockSearch();
|
|
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
|
|
useStockOverview();
|
|
const selectedSymbol = selectedStock?.symbol;
|
|
|
|
/**
|
|
* [Effect] query string이 남아 들어온 경우 즉시 깨끗한 /trade URL로 정리합니다.
|
|
* 과거 링크/브라우저 히스토리로 유입되는 query 오염을 제거하기 위한 방어 로직입니다.
|
|
*/
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
if (!window.location.search) return;
|
|
router.replace("/trade");
|
|
}, [router]);
|
|
|
|
/**
|
|
* [Effect] Dashboard에서 넘긴 종목을 1회 소비해 자동 로드합니다.
|
|
* @remarks UI 흐름: Dashboard 종목 클릭 -> useTradeNavigationStore.setPendingTarget -> /trade -> consumePendingTarget -> loadOverview
|
|
*/
|
|
useEffect(() => {
|
|
if (!isKisVerified || !verifiedCredentials || !_hasHydrated) {
|
|
return;
|
|
}
|
|
|
|
const pendingTarget = consumePendingTarget();
|
|
if (!pendingTarget) return;
|
|
|
|
if (selectedSymbol === pendingTarget.symbol) {
|
|
return;
|
|
}
|
|
|
|
setKeyword(pendingTarget.name || pendingTarget.symbol);
|
|
appendSearchHistory({
|
|
symbol: pendingTarget.symbol,
|
|
name: pendingTarget.name || pendingTarget.symbol,
|
|
market: pendingTarget.market,
|
|
});
|
|
loadOverview(
|
|
pendingTarget.symbol,
|
|
verifiedCredentials,
|
|
pendingTarget.market,
|
|
);
|
|
}, [
|
|
isKisVerified,
|
|
verifiedCredentials,
|
|
_hasHydrated,
|
|
consumePendingTarget,
|
|
selectedSymbol,
|
|
loadOverview,
|
|
setKeyword,
|
|
appendSearchHistory,
|
|
]);
|
|
|
|
const canTrade = isKisVerified && !!verifiedCredentials;
|
|
const canSearch = canTrade;
|
|
|
|
/**
|
|
* @description 상단 보유 요약 노출을 위해 잔고를 조회합니다.
|
|
* @summary UI 흐름: TradeContainer -> loadHoldingsSnapshot -> fetchDashboardBalance -> holdings 상태 업데이트
|
|
* @see features/dashboard/apis/dashboard.api.ts - fetchDashboardBalance 잔고 API를 재사용합니다.
|
|
*/
|
|
const loadHoldingsSnapshot = useCallback(async () => {
|
|
if (!verifiedCredentials?.accountNo?.trim()) {
|
|
setHoldings([]);
|
|
setAvailableCashBalance(null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const balance = await fetchDashboardBalance(verifiedCredentials);
|
|
setHoldings(balance.holdings.filter((item) => item.quantity > 0));
|
|
setAvailableCashBalance(balance.summary.cashBalance);
|
|
} catch {
|
|
// 상단 요약은 보조 정보이므로 실패 시 화면 전체를 막지 않고 빈 상태로 처리합니다.
|
|
setHoldings([]);
|
|
setAvailableCashBalance(null);
|
|
}
|
|
}, [verifiedCredentials]);
|
|
|
|
/**
|
|
* [Effect] 보유종목 스냅샷 주기 갱신
|
|
* @remarks UI 흐름: trade 진입 -> 잔고 조회 -> selectedStock과 symbol 매칭 -> 상단 보유수량/손익 표기
|
|
*/
|
|
useEffect(() => {
|
|
if (!canTrade || !verifiedCredentials?.accountNo?.trim()) {
|
|
return;
|
|
}
|
|
|
|
const initialTimerId = window.setTimeout(() => {
|
|
void loadHoldingsSnapshot();
|
|
}, 0);
|
|
|
|
const intervalId = window.setInterval(() => {
|
|
void loadHoldingsSnapshot();
|
|
}, 60_000);
|
|
|
|
return () => {
|
|
window.clearTimeout(initialTimerId);
|
|
window.clearInterval(intervalId);
|
|
};
|
|
}, [canTrade, verifiedCredentials?.accountNo, loadHoldingsSnapshot]);
|
|
|
|
const matchedHolding = useMemo(() => {
|
|
if (!canTrade || !selectedSymbol) return null;
|
|
return holdings.find((item) => item.symbol === selectedSymbol) ?? null;
|
|
}, [canTrade, holdings, selectedSymbol]);
|
|
|
|
const {
|
|
searchShellRef,
|
|
isSearchPanelOpen,
|
|
markSkipNextAutoSearch,
|
|
openSearchPanel,
|
|
closeSearchPanel,
|
|
handleSearchShellBlur,
|
|
handleSearchShellKeyDown,
|
|
} = useTradeSearchPanel({
|
|
canSearch,
|
|
keyword,
|
|
verifiedCredentials,
|
|
search,
|
|
clearSearch,
|
|
});
|
|
|
|
/**
|
|
* @description 체결 WS에서 전달받은 실시간 호가를 상태에 저장합니다.
|
|
* @see features/trade/hooks/useKisTradeWebSocket.ts onOrderBookMessage 콜백
|
|
* @see features/trade/hooks/useOrderBook.ts externalRealtimeOrderBook 주입
|
|
*/
|
|
const handleOrderBookMessage = useCallback(
|
|
(data: DashboardStockOrderBookResponse) => {
|
|
setRealtimeOrderBook(data);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 1. Trade WebSocket (체결 + 호가 통합)
|
|
const { latestTick, recentTradeTicks } = useKisTradeWebSocket(
|
|
selectedSymbol,
|
|
verifiedCredentials,
|
|
isKisVerified,
|
|
updateRealtimeTradeTick,
|
|
{
|
|
orderBookSymbol: selectedSymbol,
|
|
orderBookMarket: selectedStock?.market,
|
|
onOrderBookMessage: handleOrderBookMessage,
|
|
},
|
|
);
|
|
|
|
// 2. OrderBook (REST 초기 조회 + WS 실시간 병합)
|
|
const { orderBook, isLoading: isOrderBookLoading } = useOrderBook(
|
|
selectedSymbol,
|
|
selectedStock?.market,
|
|
verifiedCredentials,
|
|
isKisVerified,
|
|
{
|
|
enabled: !!selectedSymbol && !!verifiedCredentials && isKisVerified,
|
|
externalRealtimeOrderBook: realtimeOrderBook,
|
|
},
|
|
);
|
|
|
|
// 3. Price Calculation Logic (Hook)
|
|
const {
|
|
currentPrice,
|
|
change,
|
|
changeRate,
|
|
prevClose: referencePrice,
|
|
} = useCurrentPrice({
|
|
stock: selectedStock,
|
|
latestTick,
|
|
orderBook,
|
|
});
|
|
|
|
/**
|
|
* @description 검색 전 API 인증 여부를 확인합니다.
|
|
* @see features/trade/components/search/StockSearchForm.tsx 검색 제출 전 공통 가드로 사용합니다.
|
|
*/
|
|
const ensureSearchReady = useCallback(() => {
|
|
if (canSearch) return true;
|
|
setSearchError("API 키 검증을 먼저 완료해 주세요.");
|
|
return false;
|
|
}, [canSearch, setSearchError]);
|
|
|
|
/**
|
|
* @description 수동 검색 버튼(엔터 포함) 제출 이벤트를 처리합니다.
|
|
* @see features/trade/components/search/StockSearchForm.tsx onSubmit 이벤트로 호출됩니다.
|
|
*/
|
|
const handleSearchSubmit = useCallback(
|
|
(event: FormEvent) => {
|
|
event.preventDefault();
|
|
if (!ensureSearchReady() || !verifiedCredentials) return;
|
|
search(keyword, verifiedCredentials);
|
|
},
|
|
[ensureSearchReady, keyword, search, verifiedCredentials],
|
|
);
|
|
|
|
/**
|
|
* @description 검색 결과/히스토리에서 종목을 선택하면 종목 상세를 로드하고 히스토리를 갱신합니다.
|
|
* @see features/trade/components/search/StockSearchResults.tsx onSelect 이벤트
|
|
* @see features/trade/components/search/StockSearchHistory.tsx onSelect 이벤트
|
|
*/
|
|
const handleSelectStock = useCallback(
|
|
(item: DashboardStockSearchItem) => {
|
|
if (!ensureSearchReady() || !verifiedCredentials) return;
|
|
|
|
// 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다.
|
|
if (selectedSymbol === item.symbol) {
|
|
clearSearch();
|
|
closeSearchPanel();
|
|
return;
|
|
}
|
|
|
|
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 실행되지 않게 한 번 건너뜁니다.
|
|
markSkipNextAutoSearch();
|
|
setKeyword(item.name);
|
|
clearSearch();
|
|
closeSearchPanel();
|
|
appendSearchHistory(item);
|
|
loadOverview(item.symbol, verifiedCredentials, item.market);
|
|
},
|
|
[
|
|
ensureSearchReady,
|
|
verifiedCredentials,
|
|
selectedSymbol,
|
|
clearSearch,
|
|
closeSearchPanel,
|
|
setKeyword,
|
|
appendSearchHistory,
|
|
loadOverview,
|
|
markSkipNextAutoSearch,
|
|
],
|
|
);
|
|
|
|
if (!_hasHydrated) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center p-6">
|
|
<LoadingSpinner />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!canTrade) {
|
|
return <TradeAccessGate canTrade={canTrade} />;
|
|
}
|
|
|
|
return (
|
|
<div className="relative flex h-full min-h-0 flex-col overflow-hidden xl:h-[calc(100dvh-4rem)]">
|
|
{/* ========== SEARCH SECTION ========== */}
|
|
<TradeSearchSection
|
|
canSearch={canSearch}
|
|
isSearchPanelOpen={isSearchPanelOpen}
|
|
isSearching={isSearching}
|
|
keyword={keyword}
|
|
selectedStock={selectedStock}
|
|
selectedSymbol={selectedSymbol}
|
|
currentPrice={currentPrice}
|
|
change={change}
|
|
changeRate={changeRate}
|
|
searchResults={searchResults}
|
|
searchHistory={searchHistory}
|
|
searchShellRef={searchShellRef}
|
|
onKeywordChange={setKeyword}
|
|
onSearchSubmit={handleSearchSubmit}
|
|
onSearchFocus={openSearchPanel}
|
|
onSearchShellBlur={handleSearchShellBlur}
|
|
onSearchShellKeyDown={handleSearchShellKeyDown}
|
|
onSelectStock={handleSelectStock}
|
|
onRemoveHistory={removeSearchHistory}
|
|
onClearHistory={clearSearchHistory}
|
|
/>
|
|
|
|
<AutotradeControlPanel
|
|
selectedStock={selectedStock}
|
|
latestTick={latestTick}
|
|
credentials={verifiedCredentials}
|
|
canTrade={canTrade}
|
|
/>
|
|
|
|
{/* ========== DASHBOARD SECTION ========== */}
|
|
<TradeDashboardContent
|
|
selectedStock={selectedStock}
|
|
verifiedCredentials={verifiedCredentials}
|
|
latestTick={latestTick}
|
|
recentTradeTicks={recentTradeTicks}
|
|
orderBook={orderBook}
|
|
isOrderBookLoading={isOrderBookLoading}
|
|
referencePrice={referencePrice}
|
|
matchedHolding={matchedHolding}
|
|
availableCashBalance={availableCashBalance}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|