Files
auto-trade/features/trade/components/TradeContainer.tsx

268 lines
8.8 KiB
TypeScript
Raw Normal View History

2026-02-10 11:16:39 +09:00
"use client";
2026-02-13 12:17:35 +09:00
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
2026-02-10 11:16:39 +09:00
import { useShallow } from "zustand/react/shallow";
2026-02-12 10:24:03 +09:00
import { LoadingSpinner } from "@/components/ui/loading-spinner";
2026-02-11 16:31:28 +09:00
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
2026-02-12 10:24:03 +09:00
import { TradeAccessGate } from "@/features/trade/components/guards/TradeAccessGate";
import { TradeDashboardContent } from "@/features/trade/components/layout/TradeDashboardContent";
import { TradeSearchSection } from "@/features/trade/components/search/TradeSearchSection";
2026-02-11 16:31:28 +09:00
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";
2026-02-12 10:24:03 +09:00
import { useTradeSearchPanel } from "@/features/trade/hooks/useTradeSearchPanel";
2026-02-10 11:16:39 +09:00
import type {
DashboardStockOrderBookResponse,
DashboardStockSearchItem,
2026-02-11 16:31:28 +09:00
} from "@/features/trade/types/trade.types";
2026-02-10 11:16:39 +09:00
/**
2026-02-11 16:31:28 +09:00
* @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 .
2026-02-10 11:16:39 +09:00
*/
2026-02-11 16:31:28 +09:00
export function TradeContainer() {
2026-02-13 12:17:35 +09:00
const searchParams = useSearchParams();
const symbolParam = searchParams.get("symbol");
const nameParam = searchParams.get("name");
2026-02-12 10:24:03 +09:00
// [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신)
const [realtimeOrderBook, setRealtimeOrderBook] =
useState<DashboardStockOrderBookResponse | null>(null);
const { verifiedCredentials, isKisVerified, _hasHydrated } =
useKisRuntimeStore(
useShallow((state) => ({
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
_hasHydrated: state._hasHydrated,
})),
);
2026-02-10 11:16:39 +09:00
const {
keyword,
setKeyword,
searchResults,
2026-02-11 14:06:06 +09:00
setSearchError,
2026-02-10 11:16:39 +09:00
isSearching,
search,
clearSearch,
2026-02-11 14:06:06 +09:00
searchHistory,
appendSearchHistory,
removeSearchHistory,
clearSearchHistory,
2026-02-10 11:16:39 +09:00
} = useStockSearch();
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
useStockOverview();
2026-02-13 12:17:35 +09:00
/**
* [Effect] URL (symbol)
* .
*/
useEffect(() => {
if (symbolParam && isKisVerified && verifiedCredentials && _hasHydrated) {
// 현재 선택된 종목과 파라미터가 다를 경우에만 자동 로드 수행
if (selectedStock?.symbol !== symbolParam) {
setKeyword(nameParam || symbolParam);
appendSearchHistory({
symbol: symbolParam,
name: nameParam || symbolParam,
market: "KOSPI", // 기본값 설정, loadOverview 이후 실제 데이터로 보완됨
});
loadOverview(symbolParam, verifiedCredentials);
}
}
}, [
symbolParam,
nameParam,
isKisVerified,
verifiedCredentials,
_hasHydrated,
selectedStock?.symbol,
loadOverview,
setKeyword,
appendSearchHistory,
]);
2026-02-12 10:24:03 +09:00
const canTrade = isKisVerified && !!verifiedCredentials;
const canSearch = canTrade;
2026-02-10 11:16:39 +09:00
2026-02-12 10:24:03 +09:00
const {
searchShellRef,
isSearchPanelOpen,
markSkipNextAutoSearch,
openSearchPanel,
closeSearchPanel,
handleSearchShellBlur,
handleSearchShellKeyDown,
} = useTradeSearchPanel({
canSearch,
keyword,
verifiedCredentials,
search,
clearSearch,
});
2026-02-10 11:16:39 +09:00
2026-02-12 10:24:03 +09:00
/**
* @description WS에서 .
* @see features/trade/hooks/useKisTradeWebSocket.ts onOrderBookMessage
* @see features/trade/hooks/useOrderBook.ts externalRealtimeOrderBook
*/
2026-02-10 11:16:39 +09:00
const handleOrderBookMessage = useCallback(
(data: DashboardStockOrderBookResponse) => {
setRealtimeOrderBook(data);
},
[],
);
// 1. Trade WebSocket (체결 + 호가 통합)
2026-02-11 11:18:15 +09:00
const { latestTick, recentTradeTicks } = useKisTradeWebSocket(
selectedStock?.symbol,
verifiedCredentials,
isKisVerified,
updateRealtimeTradeTick,
{
orderBookSymbol: selectedStock?.symbol,
onOrderBookMessage: handleOrderBookMessage,
},
);
2026-02-10 11:16:39 +09:00
// 2. OrderBook (REST 초기 조회 + WS 실시간 병합)
const { orderBook, isLoading: isOrderBookLoading } = useOrderBook(
selectedStock?.symbol,
selectedStock?.market,
verifiedCredentials,
isKisVerified,
{
enabled: !!selectedStock && !!verifiedCredentials && isKisVerified,
externalRealtimeOrderBook: realtimeOrderBook,
},
);
// 3. Price Calculation Logic (Hook)
const {
currentPrice,
change,
changeRate,
prevClose: referencePrice,
} = useCurrentPrice({
stock: selectedStock,
latestTick,
orderBook,
});
2026-02-11 14:06:06 +09:00
/**
* @description API .
2026-02-11 16:31:28 +09:00
* @see features/trade/components/search/StockSearchForm.tsx .
2026-02-11 14:06:06 +09:00
*/
const ensureSearchReady = useCallback(() => {
if (canSearch) return true;
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return false;
}, [canSearch, setSearchError]);
/**
* @description ( ) .
2026-02-11 16:31:28 +09:00
* @see features/trade/components/search/StockSearchForm.tsx onSubmit .
2026-02-11 14:06:06 +09:00
*/
const handleSearchSubmit = useCallback(
2026-02-12 10:24:03 +09:00
(event: FormEvent) => {
2026-02-11 14:06:06 +09:00
event.preventDefault();
if (!ensureSearchReady() || !verifiedCredentials) return;
search(keyword, verifiedCredentials);
},
[ensureSearchReady, keyword, search, verifiedCredentials],
);
2026-02-10 11:16:39 +09:00
2026-02-11 14:06:06 +09:00
/**
* @description / .
2026-02-11 16:31:28 +09:00
* @see features/trade/components/search/StockSearchResults.tsx onSelect
* @see features/trade/components/search/StockSearchHistory.tsx onSelect
2026-02-11 14:06:06 +09:00
*/
const handleSelectStock = useCallback(
(item: DashboardStockSearchItem) => {
if (!ensureSearchReady() || !verifiedCredentials) return;
// 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다.
if (selectedStock?.symbol === item.symbol) {
clearSearch();
closeSearchPanel();
return;
}
2026-02-10 11:16:39 +09:00
2026-02-11 14:06:06 +09:00
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 실행되지 않게 한 번 건너뜁니다.
2026-02-12 10:24:03 +09:00
markSkipNextAutoSearch();
2026-02-11 14:06:06 +09:00
setKeyword(item.name);
clearSearch();
closeSearchPanel();
appendSearchHistory(item);
loadOverview(item.symbol, verifiedCredentials, item.market);
},
[
ensureSearchReady,
verifiedCredentials,
selectedStock?.symbol,
clearSearch,
closeSearchPanel,
setKeyword,
appendSearchHistory,
loadOverview,
2026-02-12 10:24:03 +09:00
markSkipNextAutoSearch,
2026-02-11 14:06:06 +09:00
],
);
2026-02-10 11:16:39 +09:00
2026-02-12 10:24:03 +09:00
if (!_hasHydrated) {
2026-02-11 16:31:28 +09:00
return (
<div className="flex h-full items-center justify-center p-6">
2026-02-12 10:24:03 +09:00
<LoadingSpinner />
2026-02-10 11:16:39 +09:00
</div>
2026-02-11 16:31:28 +09:00
);
}
2026-02-10 11:16:39 +09:00
2026-02-12 10:24:03 +09:00
if (!canTrade) {
return <TradeAccessGate canTrade={canTrade} />;
}
2026-02-11 16:31:28 +09:00
return (
<div className="relative h-full flex flex-col">
2026-02-12 10:24:03 +09:00
{/* ========== SEARCH SECTION ========== */}
<TradeSearchSection
canSearch={canSearch}
isSearchPanelOpen={isSearchPanelOpen}
isSearching={isSearching}
keyword={keyword}
selectedSymbol={selectedStock?.symbol}
searchResults={searchResults}
searchHistory={searchHistory}
searchShellRef={searchShellRef}
onKeywordChange={setKeyword}
onSearchSubmit={handleSearchSubmit}
onSearchFocus={openSearchPanel}
onSearchShellBlur={handleSearchShellBlur}
onSearchShellKeyDown={handleSearchShellKeyDown}
onSelectStock={handleSelectStock}
onRemoveHistory={removeSearchHistory}
onClearHistory={clearSearchHistory}
/>
{/* ========== DASHBOARD SECTION ========== */}
<TradeDashboardContent
selectedStock={selectedStock}
verifiedCredentials={verifiedCredentials}
latestTick={latestTick}
recentTradeTicks={recentTradeTicks}
orderBook={orderBook}
isOrderBookLoading={isOrderBookLoading}
referencePrice={referencePrice}
currentPrice={currentPrice}
change={change}
changeRate={changeRate}
/>
2026-02-10 11:16:39 +09:00
</div>
);
}