2026-02-10 11:16:39 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2026-02-11 16:31:28 +09:00
|
|
|
import Link from "next/link";
|
2026-02-10 11:16:39 +09:00
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
|
import { useShallow } from "zustand/react/shallow";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2026-02-11 16:31:28 +09:00
|
|
|
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
|
|
|
|
import { StockSearchForm } from "@/features/trade/components/search/StockSearchForm";
|
|
|
|
|
import { StockSearchHistory } from "@/features/trade/components/search/StockSearchHistory";
|
|
|
|
|
import { StockSearchResults } from "@/features/trade/components/search/StockSearchResults";
|
|
|
|
|
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 { DashboardLayout } from "@/features/trade/components/layout/DashboardLayout";
|
|
|
|
|
import { StockHeader } from "@/features/trade/components/header/StockHeader";
|
|
|
|
|
import { OrderBook } from "@/features/trade/components/orderbook/OrderBook";
|
|
|
|
|
import { OrderForm } from "@/features/trade/components/order/OrderForm";
|
|
|
|
|
import { StockLineChart } from "@/features/trade/components/chart/StockLineChart";
|
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-10 11:16:39 +09:00
|
|
|
const skipNextAutoSearchRef = useRef(false);
|
2026-02-11 14:06:06 +09:00
|
|
|
const searchShellRef = useRef<HTMLDivElement | null>(null);
|
2026-02-10 11:16:39 +09:00
|
|
|
|
2026-02-11 16:31:28 +09:00
|
|
|
// 상태 정의: 검색 패널 열림 상태를 관리합니다.
|
2026-02-11 14:06:06 +09:00
|
|
|
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
|
2026-02-10 11:16:39 +09:00
|
|
|
|
|
|
|
|
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
|
|
|
|
|
useShallow((state) => ({
|
|
|
|
|
verifiedCredentials: state.verifiedCredentials,
|
|
|
|
|
isKisVerified: state.isKisVerified,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
// 호가 실시간 데이터 (체결 WS에서 동일 소켓으로 수신)
|
|
|
|
|
const [realtimeOrderBook, setRealtimeOrderBook] =
|
|
|
|
|
useState<DashboardStockOrderBookResponse | null>(null);
|
|
|
|
|
|
|
|
|
|
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 16:31:28 +09:00
|
|
|
const canTrade = isKisVerified && !!verifiedCredentials;
|
|
|
|
|
const canSearch = canTrade;
|
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]);
|
|
|
|
|
|
|
|
|
|
const closeSearchPanel = useCallback(() => {
|
|
|
|
|
setIsSearchPanelOpen(false);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const openSearchPanel = useCallback(() => {
|
|
|
|
|
if (!canSearch) return;
|
|
|
|
|
setIsSearchPanelOpen(true);
|
|
|
|
|
}, [canSearch]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 검색 영역 포커스가 완전히 빠지면 드롭다운(검색결과/히스토리)을 닫습니다.
|
2026-02-11 16:31:28 +09:00
|
|
|
* @see features/trade/components/search/StockSearchForm.tsx 입력 포커스 이벤트에서 열림 제어를 함께 사용합니다.
|
2026-02-11 14:06:06 +09:00
|
|
|
*/
|
|
|
|
|
const handleSearchShellBlur = useCallback(
|
|
|
|
|
(event: React.FocusEvent<HTMLDivElement>) => {
|
|
|
|
|
const nextTarget = event.relatedTarget as Node | null;
|
|
|
|
|
if (nextTarget && searchShellRef.current?.contains(nextTarget)) return;
|
|
|
|
|
closeSearchPanel();
|
|
|
|
|
},
|
|
|
|
|
[closeSearchPanel],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleSearchShellKeyDown = useCallback(
|
|
|
|
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
|
|
|
if (event.key !== "Escape") return;
|
|
|
|
|
closeSearchPanel();
|
|
|
|
|
(event.target as HTMLElement | null)?.blur?.();
|
|
|
|
|
},
|
|
|
|
|
[closeSearchPanel],
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-10 11:16:39 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (skipNextAutoSearchRef.current) {
|
|
|
|
|
skipNextAutoSearchRef.current = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 14:06:06 +09:00
|
|
|
if (!canSearch) {
|
2026-02-10 11:16:39 +09:00
|
|
|
clearSearch();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trimmed = keyword.trim();
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
clearSearch();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const timer = window.setTimeout(() => {
|
|
|
|
|
search(trimmed, verifiedCredentials);
|
|
|
|
|
}, 220);
|
|
|
|
|
|
|
|
|
|
return () => window.clearTimeout(timer);
|
2026-02-11 14:06:06 +09:00
|
|
|
}, [canSearch, keyword, verifiedCredentials, search, clearSearch]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @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(
|
|
|
|
|
(event: React.FormEvent) => {
|
|
|
|
|
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가 바뀔 때 자동 검색이 다시 실행되지 않게 한 번 건너뜁니다.
|
|
|
|
|
skipNextAutoSearchRef.current = true;
|
|
|
|
|
setKeyword(item.name);
|
|
|
|
|
clearSearch();
|
|
|
|
|
closeSearchPanel();
|
|
|
|
|
appendSearchHistory(item);
|
|
|
|
|
loadOverview(item.symbol, verifiedCredentials, item.market);
|
|
|
|
|
},
|
|
|
|
|
[
|
|
|
|
|
ensureSearchReady,
|
|
|
|
|
verifiedCredentials,
|
|
|
|
|
selectedStock?.symbol,
|
|
|
|
|
clearSearch,
|
|
|
|
|
closeSearchPanel,
|
|
|
|
|
setKeyword,
|
|
|
|
|
appendSearchHistory,
|
|
|
|
|
loadOverview,
|
|
|
|
|
],
|
|
|
|
|
);
|
2026-02-10 11:16:39 +09:00
|
|
|
|
2026-02-11 16:31:28 +09:00
|
|
|
if (!canTrade) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full items-center justify-center p-6">
|
|
|
|
|
<section className="w-full max-w-xl rounded-2xl border border-brand-200 bg-background p-6 shadow-sm dark:border-brand-800/45 dark:bg-brand-900/18">
|
|
|
|
|
{/* ========== UNVERIFIED NOTICE ========== */}
|
|
|
|
|
<h2 className="text-lg font-semibold text-foreground">
|
|
|
|
|
트레이딩을 시작하려면 KIS API 인증이 필요합니다.
|
|
|
|
|
</h2>
|
|
|
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
|
|
|
설정 페이지에서 App Key/App Secret을 입력하고 연결 상태를 확인해
|
|
|
|
|
주세요.
|
|
|
|
|
</p>
|
|
|
|
|
<div className="mt-4">
|
|
|
|
|
<Button asChild className="bg-brand-600 hover:bg-brand-700">
|
|
|
|
|
<Link href="/settings">설정 페이지로 이동</Link>
|
|
|
|
|
</Button>
|
2026-02-10 11:16:39 +09:00
|
|
|
</div>
|
2026-02-11 16:31:28 +09:00
|
|
|
</section>
|
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-11 16:31:28 +09:00
|
|
|
return (
|
|
|
|
|
<div className="relative h-full flex flex-col">
|
2026-02-10 11:16:39 +09:00
|
|
|
{/* ========== SEARCH ========== */}
|
2026-02-11 14:06:06 +09:00
|
|
|
<div className="z-30 flex-none border-b bg-background/95 p-4 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22">
|
|
|
|
|
<div
|
|
|
|
|
ref={searchShellRef}
|
|
|
|
|
onBlurCapture={handleSearchShellBlur}
|
|
|
|
|
onKeyDownCapture={handleSearchShellKeyDown}
|
|
|
|
|
className="relative mx-auto max-w-2xl"
|
|
|
|
|
>
|
2026-02-10 11:16:39 +09:00
|
|
|
<StockSearchForm
|
|
|
|
|
keyword={keyword}
|
|
|
|
|
onKeywordChange={setKeyword}
|
|
|
|
|
onSubmit={handleSearchSubmit}
|
2026-02-11 14:06:06 +09:00
|
|
|
onInputFocus={openSearchPanel}
|
|
|
|
|
disabled={!canSearch}
|
2026-02-10 11:16:39 +09:00
|
|
|
isLoading={isSearching}
|
|
|
|
|
/>
|
2026-02-11 14:06:06 +09:00
|
|
|
|
|
|
|
|
{isSearchPanelOpen && canSearch && (
|
|
|
|
|
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-x-hidden overflow-y-auto rounded-md border bg-background shadow-lg dark:border-brand-800/45 dark:bg-brand-950/95">
|
|
|
|
|
{searchResults.length > 0 ? (
|
|
|
|
|
<StockSearchResults
|
|
|
|
|
items={searchResults}
|
|
|
|
|
onSelect={handleSelectStock}
|
|
|
|
|
selectedSymbol={selectedStock?.symbol}
|
|
|
|
|
/>
|
|
|
|
|
) : keyword.trim() ? (
|
|
|
|
|
<div className="px-3 py-2 text-sm text-muted-foreground">
|
|
|
|
|
{isSearching ? "검색 중..." : "검색 결과가 없습니다."}
|
|
|
|
|
</div>
|
|
|
|
|
) : searchHistory.length > 0 ? (
|
|
|
|
|
<StockSearchHistory
|
|
|
|
|
items={searchHistory}
|
|
|
|
|
onSelect={handleSelectStock}
|
|
|
|
|
onRemove={removeSearchHistory}
|
|
|
|
|
onClear={clearSearchHistory}
|
|
|
|
|
selectedSymbol={selectedStock?.symbol}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="px-3 py-2 text-sm text-muted-foreground">
|
|
|
|
|
최근 검색 종목이 없습니다.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-10 11:16:39 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ========== MAIN CONTENT ========== */}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"transition-opacity duration-200 h-full flex-1 min-h-0 overflow-x-hidden",
|
|
|
|
|
!selectedStock && "opacity-20 pointer-events-none",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<DashboardLayout
|
|
|
|
|
header={
|
|
|
|
|
selectedStock ? (
|
|
|
|
|
<StockHeader
|
|
|
|
|
stock={selectedStock}
|
|
|
|
|
price={currentPrice?.toLocaleString() ?? "0"}
|
|
|
|
|
change={change?.toLocaleString() ?? "0"}
|
|
|
|
|
changeRate={changeRate?.toFixed(2) ?? "0.00"}
|
2026-02-11 14:06:06 +09:00
|
|
|
high={latestTick ? latestTick.high.toLocaleString() : undefined}
|
2026-02-10 11:16:39 +09:00
|
|
|
low={latestTick ? latestTick.low.toLocaleString() : undefined}
|
|
|
|
|
volume={
|
|
|
|
|
latestTick
|
|
|
|
|
? latestTick.accumulatedVolume.toLocaleString()
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
) : null
|
|
|
|
|
}
|
|
|
|
|
chart={
|
|
|
|
|
selectedStock ? (
|
|
|
|
|
<div className="p-0 h-full flex flex-col">
|
|
|
|
|
<StockLineChart
|
|
|
|
|
symbol={selectedStock.symbol}
|
2026-02-11 11:18:15 +09:00
|
|
|
candles={selectedStock.candles}
|
2026-02-10 11:16:39 +09:00
|
|
|
credentials={verifiedCredentials}
|
2026-02-11 11:18:15 +09:00
|
|
|
latestTick={latestTick}
|
2026-02-10 11:16:39 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="h-full flex items-center justify-center text-muted-foreground">
|
|
|
|
|
차트 영역
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
orderBook={
|
|
|
|
|
<OrderBook
|
|
|
|
|
symbol={selectedStock?.symbol}
|
|
|
|
|
referencePrice={referencePrice}
|
|
|
|
|
currentPrice={currentPrice}
|
|
|
|
|
latestTick={latestTick}
|
|
|
|
|
recentTicks={recentTradeTicks}
|
|
|
|
|
orderBook={orderBook}
|
|
|
|
|
isLoading={isOrderBookLoading}
|
|
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|