정리
This commit is contained in:
@@ -1,24 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { type FormEvent, useCallback, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
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 { TradeAccessGate } from "@/features/trade/components/guards/TradeAccessGate";
|
||||
import { TradeDashboardContent } from "@/features/trade/components/layout/TradeDashboardContent";
|
||||
import { TradeSearchSection } from "@/features/trade/components/search/TradeSearchSection";
|
||||
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";
|
||||
import { useTradeSearchPanel } from "@/features/trade/hooks/useTradeSearchPanel";
|
||||
import type {
|
||||
DashboardStockOrderBookResponse,
|
||||
DashboardStockSearchItem,
|
||||
@@ -32,18 +26,17 @@ import type {
|
||||
* @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
|
||||
*/
|
||||
export function TradeContainer() {
|
||||
const skipNextAutoSearchRef = useRef(false);
|
||||
const searchShellRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 상태 정의: 검색 패널 열림 상태를 관리합니다.
|
||||
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
|
||||
|
||||
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
isKisVerified: state.isKisVerified,
|
||||
})),
|
||||
);
|
||||
// [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,
|
||||
})),
|
||||
);
|
||||
|
||||
const {
|
||||
keyword,
|
||||
@@ -58,14 +51,32 @@ export function TradeContainer() {
|
||||
removeSearchHistory,
|
||||
clearSearchHistory,
|
||||
} = useStockSearch();
|
||||
|
||||
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
|
||||
useStockOverview();
|
||||
const canTrade = isKisVerified && !!verifiedCredentials;
|
||||
const canSearch = canTrade;
|
||||
|
||||
// 호가 실시간 데이터 (체결 WS에서 동일 소켓으로 수신)
|
||||
const [realtimeOrderBook, setRealtimeOrderBook] =
|
||||
useState<DashboardStockOrderBookResponse | null>(null);
|
||||
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);
|
||||
@@ -109,9 +120,6 @@ export function TradeContainer() {
|
||||
orderBook,
|
||||
});
|
||||
|
||||
const canTrade = isKisVerified && !!verifiedCredentials;
|
||||
const canSearch = canTrade;
|
||||
|
||||
/**
|
||||
* @description 검색 전 API 인증 여부를 확인합니다.
|
||||
* @see features/trade/components/search/StockSearchForm.tsx 검색 제출 전 공통 가드로 사용합니다.
|
||||
@@ -122,67 +130,12 @@ export function TradeContainer() {
|
||||
return false;
|
||||
}, [canSearch, setSearchError]);
|
||||
|
||||
const closeSearchPanel = useCallback(() => {
|
||||
setIsSearchPanelOpen(false);
|
||||
}, []);
|
||||
|
||||
const openSearchPanel = useCallback(() => {
|
||||
if (!canSearch) return;
|
||||
setIsSearchPanelOpen(true);
|
||||
}, [canSearch]);
|
||||
|
||||
/**
|
||||
* @description 검색 영역 포커스가 완전히 빠지면 드롭다운(검색결과/히스토리)을 닫습니다.
|
||||
* @see features/trade/components/search/StockSearchForm.tsx 입력 포커스 이벤트에서 열림 제어를 함께 사용합니다.
|
||||
*/
|
||||
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],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipNextAutoSearchRef.current) {
|
||||
skipNextAutoSearchRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canSearch) {
|
||||
clearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = keyword.trim();
|
||||
if (!trimmed) {
|
||||
clearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
search(trimmed, verifiedCredentials);
|
||||
}, 220);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [canSearch, keyword, verifiedCredentials, search, clearSearch]);
|
||||
|
||||
/**
|
||||
* @description 수동 검색 버튼(엔터 포함) 제출 이벤트를 처리합니다.
|
||||
* @see features/trade/components/search/StockSearchForm.tsx onSubmit 이벤트로 호출됩니다.
|
||||
*/
|
||||
const handleSearchSubmit = useCallback(
|
||||
(event: React.FormEvent) => {
|
||||
(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!ensureSearchReady() || !verifiedCredentials) return;
|
||||
search(keyword, verifiedCredentials);
|
||||
@@ -207,7 +160,7 @@ export function TradeContainer() {
|
||||
}
|
||||
|
||||
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 실행되지 않게 한 번 건너뜁니다.
|
||||
skipNextAutoSearchRef.current = true;
|
||||
markSkipNextAutoSearch();
|
||||
setKeyword(item.name);
|
||||
clearSearch();
|
||||
closeSearchPanel();
|
||||
@@ -223,135 +176,57 @@ export function TradeContainer() {
|
||||
setKeyword,
|
||||
appendSearchHistory,
|
||||
loadOverview,
|
||||
markSkipNextAutoSearch,
|
||||
],
|
||||
);
|
||||
|
||||
if (!canTrade) {
|
||||
if (!_hasHydrated) {
|
||||
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>
|
||||
</div>
|
||||
</section>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canTrade) {
|
||||
return <TradeAccessGate canTrade={canTrade} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex flex-col">
|
||||
{/* ========== SEARCH ========== */}
|
||||
<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"
|
||||
>
|
||||
<StockSearchForm
|
||||
keyword={keyword}
|
||||
onKeywordChange={setKeyword}
|
||||
onSubmit={handleSearchSubmit}
|
||||
onInputFocus={openSearchPanel}
|
||||
disabled={!canSearch}
|
||||
isLoading={isSearching}
|
||||
/>
|
||||
{/* ========== 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}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</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"}
|
||||
high={latestTick ? latestTick.high.toLocaleString() : undefined}
|
||||
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}
|
||||
candles={selectedStock.candles}
|
||||
credentials={verifiedCredentials}
|
||||
latestTick={latestTick}
|
||||
/>
|
||||
</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>
|
||||
{/* ========== DASHBOARD SECTION ========== */}
|
||||
<TradeDashboardContent
|
||||
selectedStock={selectedStock}
|
||||
verifiedCredentials={verifiedCredentials}
|
||||
latestTick={latestTick}
|
||||
recentTradeTicks={recentTradeTicks}
|
||||
orderBook={orderBook}
|
||||
isOrderBookLoading={isOrderBookLoading}
|
||||
referencePrice={referencePrice}
|
||||
currentPrice={currentPrice}
|
||||
change={change}
|
||||
changeRate={changeRate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
36
features/trade/components/guards/TradeAccessGate.tsx
Normal file
36
features/trade/components/guards/TradeAccessGate.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface TradeAccessGateProps {
|
||||
canTrade: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description KIS 인증 여부에 따라 트레이드 화면 접근 가이드를 렌더링합니다.
|
||||
* @see features/trade/components/TradeContainer.tsx TradeContainer의 인증 가드 UI를 분리합니다.
|
||||
* @see app/(main)/settings/page.tsx 미인증 사용자를 설정 페이지로 이동시킵니다.
|
||||
*/
|
||||
export function TradeAccessGate({ canTrade }: TradeAccessGateProps) {
|
||||
if (canTrade) return null;
|
||||
|
||||
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>
|
||||
|
||||
{/* ========== ACTION ========== */}
|
||||
<div className="mt-4">
|
||||
<Button asChild className="bg-brand-600 hover:bg-brand-700">
|
||||
<Link href="/settings">설정 페이지로 이동</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
features/trade/components/layout/TradeDashboardContent.tsx
Normal file
99
features/trade/components/layout/TradeDashboardContent.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import { StockLineChart } from "@/features/trade/components/chart/StockLineChart";
|
||||
import { StockHeader } from "@/features/trade/components/header/StockHeader";
|
||||
import { DashboardLayout } from "@/features/trade/components/layout/DashboardLayout";
|
||||
import { OrderForm } from "@/features/trade/components/order/OrderForm";
|
||||
import { OrderBook } from "@/features/trade/components/orderbook/OrderBook";
|
||||
import type {
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockItem,
|
||||
DashboardStockOrderBookResponse,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TradeDashboardContentProps {
|
||||
selectedStock: DashboardStockItem | null;
|
||||
verifiedCredentials: KisRuntimeCredentials | null;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
recentTradeTicks: DashboardRealtimeTradeTick[];
|
||||
orderBook: DashboardStockOrderBookResponse | null;
|
||||
isOrderBookLoading: boolean;
|
||||
referencePrice?: number;
|
||||
currentPrice?: number;
|
||||
change?: number;
|
||||
changeRate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 트레이드 본문(헤더/차트/호가/주문)을 조합해서 렌더링합니다.
|
||||
* @see features/trade/components/TradeContainer.tsx TradeContainer가 화면 조합 코드를 단순화하기 위해 사용합니다.
|
||||
* @see features/trade/components/layout/DashboardLayout.tsx 실제 4분할 레이아웃은 DashboardLayout에서 처리합니다.
|
||||
*/
|
||||
export function TradeDashboardContent({
|
||||
selectedStock,
|
||||
verifiedCredentials,
|
||||
latestTick,
|
||||
recentTradeTicks,
|
||||
orderBook,
|
||||
isOrderBookLoading,
|
||||
referencePrice,
|
||||
currentPrice,
|
||||
change,
|
||||
changeRate,
|
||||
}: TradeDashboardContentProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"transition-opacity duration-200 h-full flex-1 min-h-0 overflow-x-hidden",
|
||||
!selectedStock && "opacity-20 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{/* ========== DASHBOARD LAYOUT ========== */}
|
||||
<DashboardLayout
|
||||
header={
|
||||
selectedStock ? (
|
||||
<StockHeader
|
||||
stock={selectedStock}
|
||||
price={currentPrice?.toLocaleString() ?? "0"}
|
||||
change={change?.toLocaleString() ?? "0"}
|
||||
changeRate={changeRate?.toFixed(2) ?? "0.00"}
|
||||
high={latestTick ? latestTick.high.toLocaleString() : undefined}
|
||||
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}
|
||||
candles={selectedStock.candles}
|
||||
credentials={verifiedCredentials}
|
||||
latestTick={latestTick}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,52 @@ function fmtTime(hms: string) {
|
||||
return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 체결 틱 데이터에서 체결 주도 방향(매수/매도/중립)을 계산합니다.
|
||||
* @see features/trade/components/orderbook/OrderBook.tsx TradeTape 체결량 글자색 결정에 사용합니다.
|
||||
*/
|
||||
function resolveTickExecutionSide(
|
||||
tick: DashboardRealtimeTradeTick,
|
||||
olderTick?: DashboardRealtimeTradeTick,
|
||||
) {
|
||||
// 실시간 체결구분 코드(CNTG_CLS_CODE) 우선 해석
|
||||
const executionClassCode = (tick.executionClassCode ?? "").trim();
|
||||
if (executionClassCode === "1" || executionClassCode === "2") {
|
||||
return "buy" as const;
|
||||
}
|
||||
if (executionClassCode === "4" || executionClassCode === "5") {
|
||||
return "sell" as const;
|
||||
}
|
||||
|
||||
// 누적 건수 기반 데이터는 절대값이 아니라 "증분"으로 판단해야 편향을 줄일 수 있습니다.
|
||||
if (olderTick) {
|
||||
const netBuyDelta =
|
||||
tick.netBuyExecutionCount - olderTick.netBuyExecutionCount;
|
||||
if (netBuyDelta > 0) return "buy" as const;
|
||||
if (netBuyDelta < 0) return "sell" as const;
|
||||
|
||||
const buyCountDelta = tick.buyExecutionCount - olderTick.buyExecutionCount;
|
||||
const sellCountDelta =
|
||||
tick.sellExecutionCount - olderTick.sellExecutionCount;
|
||||
if (buyCountDelta > sellCountDelta) return "buy" as const;
|
||||
if (buyCountDelta < sellCountDelta) return "sell" as const;
|
||||
}
|
||||
|
||||
if (tick.askPrice1 > 0 && tick.bidPrice1 > 0) {
|
||||
if (tick.price >= tick.askPrice1 && tick.price > tick.bidPrice1) {
|
||||
return "buy" as const;
|
||||
}
|
||||
if (tick.price <= tick.bidPrice1 && tick.price < tick.askPrice1) {
|
||||
return "sell" as const;
|
||||
}
|
||||
}
|
||||
|
||||
if (tick.tradeStrength > 100) return "buy" as const;
|
||||
if (tick.tradeStrength < 100) return "sell" as const;
|
||||
|
||||
return "neutral" as const;
|
||||
}
|
||||
|
||||
// ─── 메인 컴포넌트 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -305,13 +351,17 @@ function BookSideRows({
|
||||
{isAsk && (
|
||||
<>
|
||||
<DepthBar ratio={ratio} side="ask" />
|
||||
<AnimatedQuantity
|
||||
value={row.size}
|
||||
format={fmt}
|
||||
useColor
|
||||
side="ask"
|
||||
className="relative z-10"
|
||||
/>
|
||||
{row.size > 0 ? (
|
||||
<AnimatedQuantity
|
||||
value={row.size}
|
||||
format={fmt}
|
||||
useColor
|
||||
side="ask"
|
||||
className="relative z-10"
|
||||
/>
|
||||
) : (
|
||||
<span className="relative z-10 text-transparent">0</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -350,13 +400,17 @@ function BookSideRows({
|
||||
{!isAsk && (
|
||||
<>
|
||||
<DepthBar ratio={ratio} side="bid" />
|
||||
<AnimatedQuantity
|
||||
value={row.size}
|
||||
format={fmt}
|
||||
useColor
|
||||
side="bid"
|
||||
className="relative z-10"
|
||||
/>
|
||||
{row.size > 0 ? (
|
||||
<AnimatedQuantity
|
||||
value={row.size}
|
||||
format={fmt}
|
||||
useColor
|
||||
side="bid"
|
||||
className="relative z-10"
|
||||
/>
|
||||
) : (
|
||||
<span className="relative z-10 text-transparent">0</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -506,25 +560,42 @@ function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
||||
체결 데이터가 아직 없습니다.
|
||||
</div>
|
||||
)}
|
||||
{ticks.map((t, i) => (
|
||||
<div
|
||||
key={`${t.tickTime}-${t.price}-${i}`}
|
||||
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs dark:border-brand-800/35"
|
||||
>
|
||||
<div className="flex items-center tabular-nums">
|
||||
{fmtTime(t.tickTime)}
|
||||
{ticks.map((t, i) => {
|
||||
const olderTick = ticks[i + 1];
|
||||
const executionSide = resolveTickExecutionSide(t, olderTick);
|
||||
// UI 흐름: 체결목록 UI -> resolveTickExecutionSide(현재/이전 틱) -> 색상 class 결정 -> 체결량 렌더 반영
|
||||
const volumeToneClass =
|
||||
executionSide === "buy"
|
||||
? "text-red-600"
|
||||
: executionSide === "sell"
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-muted-foreground dark:text-brand-100/70";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${t.tickTime}-${t.price}-${i}`}
|
||||
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs dark:border-brand-800/35"
|
||||
>
|
||||
<div className="flex items-center tabular-nums">
|
||||
{fmtTime(t.tickTime)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums text-red-600">
|
||||
{fmt(t.price)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end tabular-nums",
|
||||
volumeToneClass,
|
||||
)}
|
||||
>
|
||||
{fmt(t.tradeVolume)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums">
|
||||
{t.tradeStrength.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums text-red-600">
|
||||
{fmt(t.price)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums text-blue-600 dark:text-blue-400">
|
||||
{fmt(t.tradeVolume)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums">
|
||||
{t.tradeStrength.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
101
features/trade/components/search/TradeSearchSection.tsx
Normal file
101
features/trade/components/search/TradeSearchSection.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { FormEvent, KeyboardEvent, FocusEvent, MutableRefObject } from "react";
|
||||
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 type {
|
||||
DashboardStockSearchHistoryItem,
|
||||
DashboardStockSearchItem,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
|
||||
interface TradeSearchSectionProps {
|
||||
canSearch: boolean;
|
||||
isSearchPanelOpen: boolean;
|
||||
isSearching: boolean;
|
||||
keyword: string;
|
||||
selectedSymbol?: string;
|
||||
searchResults: DashboardStockSearchItem[];
|
||||
searchHistory: DashboardStockSearchHistoryItem[];
|
||||
searchShellRef: MutableRefObject<HTMLDivElement | null>;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onSearchSubmit: (event: FormEvent) => void;
|
||||
onSearchFocus: () => void;
|
||||
onSearchShellBlur: (event: FocusEvent<HTMLDivElement>) => void;
|
||||
onSearchShellKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
|
||||
onSelectStock: (item: DashboardStockSearchItem) => void;
|
||||
onRemoveHistory: (symbol: string) => void;
|
||||
onClearHistory: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 트레이드 화면 상단의 검색 입력/결과/히스토리 드롭다운 영역을 렌더링합니다.
|
||||
* @see features/trade/components/TradeContainer.tsx TradeContainer에서 검색 섹션을 분리해 렌더 복잡도를 줄입니다.
|
||||
* @see features/trade/hooks/useTradeSearchPanel.ts 패널 열림/닫힘 및 포커스 핸들러를 전달받습니다.
|
||||
*/
|
||||
export function TradeSearchSection({
|
||||
canSearch,
|
||||
isSearchPanelOpen,
|
||||
isSearching,
|
||||
keyword,
|
||||
selectedSymbol,
|
||||
searchResults,
|
||||
searchHistory,
|
||||
searchShellRef,
|
||||
onKeywordChange,
|
||||
onSearchSubmit,
|
||||
onSearchFocus,
|
||||
onSearchShellBlur,
|
||||
onSearchShellKeyDown,
|
||||
onSelectStock,
|
||||
onRemoveHistory,
|
||||
onClearHistory,
|
||||
}: TradeSearchSectionProps) {
|
||||
return (
|
||||
<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">
|
||||
{/* ========== SEARCH SHELL ========== */}
|
||||
<div
|
||||
ref={searchShellRef}
|
||||
onBlurCapture={onSearchShellBlur}
|
||||
onKeyDownCapture={onSearchShellKeyDown}
|
||||
className="relative mx-auto max-w-2xl"
|
||||
>
|
||||
<StockSearchForm
|
||||
keyword={keyword}
|
||||
onKeywordChange={onKeywordChange}
|
||||
onSubmit={onSearchSubmit}
|
||||
onInputFocus={onSearchFocus}
|
||||
disabled={!canSearch}
|
||||
isLoading={isSearching}
|
||||
/>
|
||||
|
||||
{/* ========== SEARCH DROPDOWN ========== */}
|
||||
{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={onSelectStock}
|
||||
selectedSymbol={selectedSymbol}
|
||||
/>
|
||||
) : keyword.trim() ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{isSearching ? "검색 중..." : "검색 결과가 없습니다."}
|
||||
</div>
|
||||
) : searchHistory.length > 0 ? (
|
||||
<StockSearchHistory
|
||||
items={searchHistory}
|
||||
onSelect={onSelectStock}
|
||||
onRemove={onRemoveHistory}
|
||||
onClear={onClearHistory}
|
||||
selectedSymbol={selectedSymbol}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
최근 검색 종목이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,28 +23,128 @@ import {
|
||||
const TRADE_TR_ID = "H0STCNT0";
|
||||
const TRADE_TR_ID_EXPECTED = "H0STANC0";
|
||||
const TRADE_TR_ID_OVERTIME = "H0STOUP0";
|
||||
const TRADE_TR_ID_OVERTIME_EXPECTED = "H0STOAC0";
|
||||
const TRADE_TR_ID_TOTAL = "H0UNCNT0";
|
||||
const TRADE_TR_ID_TOTAL_EXPECTED = "H0UNANC0";
|
||||
const ORDERBOOK_TR_ID = "H0STASP0";
|
||||
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0";
|
||||
const MAX_TRADE_TICKS = 10;
|
||||
const WS_DEBUG_STORAGE_KEY = "KIS_WS_DEBUG";
|
||||
|
||||
function resolveTradeTrId(
|
||||
/**
|
||||
* @description 장 구간/시장별 누락을 줄이기 위해 TR ID를 우선순위 배열로 반환합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts useEffect WebSocket 구독 등록
|
||||
* @see temp-kis-domestic-functions-ws.py ccnl_krx/ccnl_total/exp_ccnl_krx/exp_ccnl_total/overtime_ccnl_krx
|
||||
*/
|
||||
function resolveTradeTrIds(
|
||||
env: KisRuntimeCredentials["tradingEnv"],
|
||||
session: DomesticKisSession,
|
||||
) {
|
||||
if (env === "mock") return TRADE_TR_ID;
|
||||
if (shouldUseAfterHoursSinglePriceTr(session)) return TRADE_TR_ID;
|
||||
if (shouldUseExpectedExecutionTr(session)) return TRADE_TR_ID;
|
||||
return TRADE_TR_ID;
|
||||
if (env === "mock") return [TRADE_TR_ID];
|
||||
|
||||
if (shouldUseAfterHoursSinglePriceTr(session)) {
|
||||
// 시간외 단일가(16:00~18:00): 전용 TR + 통합 TR 백업
|
||||
return uniqueTrIds([
|
||||
TRADE_TR_ID_OVERTIME,
|
||||
TRADE_TR_ID_OVERTIME_EXPECTED,
|
||||
TRADE_TR_ID_TOTAL,
|
||||
TRADE_TR_ID_TOTAL_EXPECTED,
|
||||
]);
|
||||
}
|
||||
|
||||
if (shouldUseExpectedExecutionTr(session)) {
|
||||
// 동시호가 구간(장전/장마감): 예상체결 TR을 우선, 일반체결/통합체결을 백업
|
||||
return uniqueTrIds([
|
||||
TRADE_TR_ID_EXPECTED,
|
||||
TRADE_TR_ID_TOTAL_EXPECTED,
|
||||
TRADE_TR_ID,
|
||||
TRADE_TR_ID_TOTAL,
|
||||
]);
|
||||
}
|
||||
|
||||
if (session === "afterCloseFixedPrice") {
|
||||
// 시간외 종가(15:40~16:00): 브로커별 라우팅 차이를 대비해 일반/시간외/통합 TR을 함께 구독
|
||||
return uniqueTrIds([
|
||||
TRADE_TR_ID,
|
||||
TRADE_TR_ID_TOTAL,
|
||||
TRADE_TR_ID_OVERTIME,
|
||||
TRADE_TR_ID_OVERTIME_EXPECTED,
|
||||
TRADE_TR_ID_TOTAL_EXPECTED,
|
||||
]);
|
||||
}
|
||||
|
||||
return uniqueTrIds([TRADE_TR_ID, TRADE_TR_ID_TOTAL]);
|
||||
}
|
||||
|
||||
function resolveOrderBookTrId(
|
||||
/**
|
||||
* @description 장 구간별 호가 TR ID 후보를 우선순위 배열로 반환합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts useEffect WebSocket 구독 등록
|
||||
* @see temp-kis-domestic-functions-ws.py asking_price_krx/asking_price_total/overtime_asking_price_krx
|
||||
*/
|
||||
function resolveOrderBookTrIds(
|
||||
env: KisRuntimeCredentials["tradingEnv"],
|
||||
session: DomesticKisSession,
|
||||
) {
|
||||
if (env === "mock") return ORDERBOOK_TR_ID;
|
||||
if (shouldUseAfterHoursSinglePriceTr(session))
|
||||
return ORDERBOOK_TR_ID_OVERTIME;
|
||||
return ORDERBOOK_TR_ID;
|
||||
if (env === "mock") return [ORDERBOOK_TR_ID];
|
||||
|
||||
if (shouldUseAfterHoursSinglePriceTr(session)) {
|
||||
// 시간외 단일가(16:00~18:00)는 KRX 전용 호가 TR만 구독합니다.
|
||||
// 통합 TR(H0UNASP0)을 같이 구독하면 종목별로 포맷/잔량이 섞여 보일 수 있습니다.
|
||||
return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME]);
|
||||
}
|
||||
|
||||
if (session === "afterCloseFixedPrice") {
|
||||
return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME]);
|
||||
}
|
||||
|
||||
// UI 흐름: 호가창 UI -> useKisTradeWebSocket onmessage -> onOrderBookMessage
|
||||
// -> TradeContainer setRealtimeOrderBook -> useOrderBook 병합 -> OrderBook 렌더
|
||||
// 장중에는 KRX 전용(H0STASP0)만 구독해 값이 번갈아 덮이는 현상을 방지합니다.
|
||||
return uniqueTrIds([ORDERBOOK_TR_ID]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 콘솔 디버그 플래그를 확인합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage
|
||||
*/
|
||||
function isWsDebugEnabled() {
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(WS_DEBUG_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 웹소켓 제어(JSON) 메시지를 파싱합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage
|
||||
*/
|
||||
function parseWsControlMessage(raw: string) {
|
||||
if (!raw.startsWith("{")) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as {
|
||||
header?: { tr_id?: string };
|
||||
body?: { rt_cd?: string; msg1?: string };
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 원문에서 파이프 구분 TR ID를 빠르게 추출합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage
|
||||
*/
|
||||
function peekPipeTrId(raw: string) {
|
||||
const parts = raw.split("|");
|
||||
return parts.length > 1 ? parts[1] : "";
|
||||
}
|
||||
|
||||
function uniqueTrIds(ids: string[]) {
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,8 +180,11 @@ export function useKisTradeWebSocket(
|
||||
|
||||
const obSymbol = options?.orderBookSymbol;
|
||||
const onOrderBookMsg = options?.onOrderBookMessage;
|
||||
const realtimeTrIds = credentials
|
||||
? resolveTradeTrIds(credentials.tradingEnv, marketSession)
|
||||
: [TRADE_TR_ID];
|
||||
const realtimeTrId = credentials
|
||||
? resolveTradeTrId(credentials.tradingEnv, marketSession)
|
||||
? realtimeTrIds[0] ?? TRADE_TR_ID
|
||||
: TRADE_TR_ID;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -122,11 +225,24 @@ export function useKisTradeWebSocket(
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
const debugEnabled = isWsDebugEnabled();
|
||||
|
||||
const tradeTrId = resolveTradeTrId(credentials.tradingEnv, marketSession);
|
||||
const orderBookTrId = obSymbol
|
||||
? resolveOrderBookTrId(credentials.tradingEnv, marketSession)
|
||||
: null;
|
||||
const tradeTrIds = resolveTradeTrIds(credentials.tradingEnv, marketSession);
|
||||
const orderBookTrIds =
|
||||
obSymbol && onOrderBookMsg
|
||||
? resolveOrderBookTrIds(credentials.tradingEnv, marketSession)
|
||||
: [];
|
||||
|
||||
const subscribe = (
|
||||
key: string,
|
||||
targetSymbol: string,
|
||||
trId: string,
|
||||
trType: "1" | "2",
|
||||
) => {
|
||||
socket?.send(
|
||||
JSON.stringify(buildKisRealtimeMessage(key, targetSymbol, trId, trType)),
|
||||
);
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
@@ -144,34 +260,31 @@ export function useKisTradeWebSocket(
|
||||
if (disposed) return;
|
||||
approvalKeyRef.current = wsConnection.approvalKey;
|
||||
|
||||
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout/${tradeTrId}`);
|
||||
// 공식 샘플과 동일하게 /tryitout 엔드포인트로 연결하고, TR은 payload로 구독합니다.
|
||||
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (disposed || !approvalKeyRef.current) return;
|
||||
|
||||
socket?.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
symbol,
|
||||
tradeTrId,
|
||||
"1",
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const trId of tradeTrIds) {
|
||||
subscribe(approvalKeyRef.current, symbol, trId, "1");
|
||||
}
|
||||
|
||||
if (obSymbol && orderBookTrId) {
|
||||
socket?.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
obSymbol,
|
||||
orderBookTrId,
|
||||
"1",
|
||||
),
|
||||
),
|
||||
);
|
||||
if (obSymbol) {
|
||||
for (const trId of orderBookTrIds) {
|
||||
subscribe(approvalKeyRef.current, obSymbol, trId, "1");
|
||||
}
|
||||
}
|
||||
|
||||
if (debugEnabled) {
|
||||
console.info("[KisRealtime] Subscribed", {
|
||||
symbol,
|
||||
marketSession,
|
||||
tradeTrIds,
|
||||
orderBookSymbol: obSymbol ?? null,
|
||||
orderBookTrIds,
|
||||
});
|
||||
}
|
||||
|
||||
setIsConnected(true);
|
||||
@@ -180,29 +293,92 @@ export function useKisTradeWebSocket(
|
||||
socket.onmessage = (event) => {
|
||||
if (disposed || typeof event.data !== "string") return;
|
||||
|
||||
const control = parseWsControlMessage(event.data);
|
||||
if (control) {
|
||||
const trId = control.header?.tr_id ?? "";
|
||||
if (trId === "PINGPONG") {
|
||||
// 서버 Keepalive에 응답하지 않으면 연결이 끊길 수 있습니다.
|
||||
socket?.send(event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (debugEnabled) {
|
||||
console.info("[KisRealtime] Control", {
|
||||
trId,
|
||||
rt_cd: control.body?.rt_cd,
|
||||
message: control.body?.msg1,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (obSymbol && onOrderBookMsg) {
|
||||
const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol);
|
||||
if (orderBook) {
|
||||
orderBook.tradingEnv = credentials.tradingEnv;
|
||||
if (debugEnabled) {
|
||||
console.debug("[KisRealtime] OrderBook", {
|
||||
trId: peekPipeTrId(event.data),
|
||||
symbol: orderBook.symbol,
|
||||
businessHour: orderBook.businessHour,
|
||||
hourClassCode: orderBook.hourClassCode,
|
||||
});
|
||||
}
|
||||
onOrderBookMsg(orderBook);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ticks = parseKisRealtimeTickBatch(event.data, symbol);
|
||||
if (ticks.length === 0) return;
|
||||
if (ticks.length === 0) {
|
||||
if (debugEnabled && event.data.includes("|")) {
|
||||
console.debug("[KisRealtime] Unparsed payload", {
|
||||
trId: peekPipeTrId(event.data),
|
||||
preview: event.data.slice(0, 220),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0);
|
||||
if (meaningfulTicks.length === 0) {
|
||||
if (debugEnabled) {
|
||||
console.debug("[KisRealtime] Ignored zero-volume ticks", {
|
||||
trId: peekPipeTrId(event.data),
|
||||
parsedCount: ticks.length,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dedupedTicks = meaningfulTicks.filter((tick) => {
|
||||
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
|
||||
if (seenTickRef.current.has(key)) return false;
|
||||
seenTickRef.current.add(key);
|
||||
if (seenTickRef.current.size > 5_000) {
|
||||
seenTickRef.current.clear();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const latest = ticks[ticks.length - 1];
|
||||
const latest = meaningfulTicks[meaningfulTicks.length - 1];
|
||||
setLatestTick(latest);
|
||||
|
||||
if (debugEnabled) {
|
||||
console.debug("[KisRealtime] Tick", {
|
||||
trId: peekPipeTrId(event.data),
|
||||
symbol: latest.symbol,
|
||||
tickTime: latest.tickTime,
|
||||
price: latest.price,
|
||||
tradeVolume: latest.tradeVolume,
|
||||
executionClassCode: latest.executionClassCode,
|
||||
buyExecutionCount: latest.buyExecutionCount,
|
||||
sellExecutionCount: latest.sellExecutionCount,
|
||||
netBuyExecutionCount: latest.netBuyExecutionCount,
|
||||
parsedCount: ticks.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (dedupedTicks.length > 0) {
|
||||
setRecentTradeTicks((prev) =>
|
||||
[...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
|
||||
@@ -215,11 +391,29 @@ export function useKisTradeWebSocket(
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
if (!disposed) setIsConnected(false);
|
||||
if (!disposed) {
|
||||
if (debugEnabled) {
|
||||
console.warn("[KisRealtime] WebSocket error", {
|
||||
symbol,
|
||||
marketSession,
|
||||
tradeTrIds,
|
||||
});
|
||||
}
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (!disposed) setIsConnected(false);
|
||||
if (!disposed) {
|
||||
if (debugEnabled) {
|
||||
console.warn("[KisRealtime] WebSocket closed", {
|
||||
symbol,
|
||||
marketSession,
|
||||
tradeTrIds,
|
||||
});
|
||||
}
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
@@ -242,16 +436,14 @@ export function useKisTradeWebSocket(
|
||||
|
||||
const key = approvalKeyRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN && key) {
|
||||
socket.send(
|
||||
JSON.stringify(buildKisRealtimeMessage(key, symbol, tradeTrId, "2")),
|
||||
);
|
||||
for (const trId of tradeTrIds) {
|
||||
subscribe(key, symbol, trId, "2");
|
||||
}
|
||||
|
||||
if (obSymbol && orderBookTrId) {
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(key, obSymbol, orderBookTrId, "2"),
|
||||
),
|
||||
);
|
||||
if (obSymbol) {
|
||||
for (const trId of orderBookTrIds) {
|
||||
subscribe(key, obSymbol, trId, "2");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) {
|
||||
*/
|
||||
export function useStockSearch() {
|
||||
// ========== SEARCH STATE ==========
|
||||
const [keyword, setKeyword] = useState("삼성전자");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<DashboardStockSearchItem[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
118
features/trade/hooks/useTradeSearchPanel.ts
Normal file
118
features/trade/hooks/useTradeSearchPanel.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
|
||||
interface UseTradeSearchPanelParams {
|
||||
canSearch: boolean;
|
||||
keyword: string;
|
||||
verifiedCredentials: KisRuntimeCredentials | null;
|
||||
search: (query: string, credentials: KisRuntimeCredentials | null) => void;
|
||||
clearSearch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 트레이드 검색 패널(열림/닫힘/자동검색/포커스 이탈) UI 상태를 관리합니다.
|
||||
* @see features/trade/components/TradeContainer.tsx TradeContainer에서 검색 관련 상태 조합을 단순화하기 위해 사용합니다.
|
||||
* @see features/trade/components/search/TradeSearchSection.tsx 검색 UI 이벤트 핸들러를 전달합니다.
|
||||
*/
|
||||
export function useTradeSearchPanel({
|
||||
canSearch,
|
||||
keyword,
|
||||
verifiedCredentials,
|
||||
search,
|
||||
clearSearch,
|
||||
}: UseTradeSearchPanelParams) {
|
||||
// [Ref] 종목 선택 직후 자동 검색을 1회 건너뛰기 위한 플래그
|
||||
const skipNextAutoSearchRef = useRef(false);
|
||||
// [Ref] 검색 패널 루트 (포커스 아웃 감지 범위)
|
||||
const searchShellRef = useRef<HTMLDivElement | null>(null);
|
||||
// [State] 검색 패널 열림 상태
|
||||
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* @description 다음 자동 검색 사이클 1회를 건너뛰도록 표시합니다.
|
||||
* @see features/trade/components/TradeContainer.tsx handleSelectStock 종목 선택 직후 중복 검색 방지에 사용합니다.
|
||||
*/
|
||||
const markSkipNextAutoSearch = useCallback(() => {
|
||||
skipNextAutoSearchRef.current = true;
|
||||
}, []);
|
||||
|
||||
const closeSearchPanel = useCallback(() => {
|
||||
setIsSearchPanelOpen(false);
|
||||
}, []);
|
||||
|
||||
const openSearchPanel = useCallback(() => {
|
||||
if (!canSearch) return;
|
||||
setIsSearchPanelOpen(true);
|
||||
}, [canSearch]);
|
||||
|
||||
/**
|
||||
* @description 검색 박스에서 포커스가 완전히 벗어나면 드롭다운을 닫습니다.
|
||||
* @see features/trade/components/search/TradeSearchSection.tsx onBlurCapture 이벤트로 연결됩니다.
|
||||
*/
|
||||
const handleSearchShellBlur = useCallback(
|
||||
(event: FocusEvent<HTMLDivElement>) => {
|
||||
const nextTarget = event.relatedTarget as Node | null;
|
||||
if (nextTarget && searchShellRef.current?.contains(nextTarget)) return;
|
||||
closeSearchPanel();
|
||||
},
|
||||
[closeSearchPanel],
|
||||
);
|
||||
|
||||
/**
|
||||
* @description ESC 키 입력 시 검색 드롭다운을 닫고 포커스를 해제합니다.
|
||||
* @see features/trade/components/search/TradeSearchSection.tsx onKeyDownCapture 이벤트로 연결됩니다.
|
||||
*/
|
||||
const handleSearchShellKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== "Escape") return;
|
||||
closeSearchPanel();
|
||||
(event.target as HTMLElement | null)?.blur?.();
|
||||
},
|
||||
[closeSearchPanel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// [Step 1] 종목 선택 직후 1회 자동 검색 스킵 처리
|
||||
if (skipNextAutoSearchRef.current) {
|
||||
skipNextAutoSearchRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// [Step 2] 인증 불가 상태면 검색 결과를 즉시 정리
|
||||
if (!canSearch) {
|
||||
clearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = keyword.trim();
|
||||
// [Step 3] 입력값이 비어 있으면 검색 상태 초기화
|
||||
if (!trimmed) {
|
||||
clearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
// [Step 4] 입력 디바운스 후 검색 실행
|
||||
const timer = window.setTimeout(() => {
|
||||
search(trimmed, verifiedCredentials);
|
||||
}, 220);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [canSearch, keyword, verifiedCredentials, search, clearSearch]);
|
||||
|
||||
return {
|
||||
searchShellRef,
|
||||
isSearchPanelOpen: canSearch && isSearchPanelOpen,
|
||||
markSkipNextAutoSearch,
|
||||
openSearchPanel,
|
||||
closeSearchPanel,
|
||||
handleSearchShellBlur,
|
||||
handleSearchShellKeyDown,
|
||||
};
|
||||
}
|
||||
@@ -153,6 +153,7 @@ export interface DashboardRealtimeTradeTick {
|
||||
sellExecutionCount: number;
|
||||
buyExecutionCount: number;
|
||||
netBuyExecutionCount: number;
|
||||
executionClassCode?: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
|
||||
@@ -4,11 +4,17 @@ import type {
|
||||
} from "@/features/trade/types/trade.types";
|
||||
|
||||
const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]);
|
||||
const ALLOWED_REALTIME_TRADE_TR_IDS = new Set([
|
||||
const EXECUTED_REALTIME_TRADE_TR_IDS = new Set([
|
||||
"H0STCNT0",
|
||||
"H0STANC0",
|
||||
"H0STOUP0",
|
||||
"H0UNCNT0",
|
||||
"H0NXCNT0",
|
||||
]);
|
||||
const EXPECTED_REALTIME_TRADE_TR_IDS = new Set([
|
||||
"H0STANC0",
|
||||
"H0STOAC0",
|
||||
"H0UNANC0",
|
||||
"H0NXANC0",
|
||||
]);
|
||||
|
||||
const TICK_FIELD_INDEX = {
|
||||
@@ -29,6 +35,7 @@ const TICK_FIELD_INDEX = {
|
||||
buyExecutionCount: 16,
|
||||
netBuyExecutionCount: 17,
|
||||
tradeStrength: 18,
|
||||
executionClassCode: 21,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -71,7 +78,10 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
||||
|
||||
// TR ID check: regular tick / expected tick / after-hours tick.
|
||||
const receivedTrId = parts[1];
|
||||
if (!ALLOWED_REALTIME_TRADE_TR_IDS.has(receivedTrId)) {
|
||||
const isExecutedTick = EXECUTED_REALTIME_TRADE_TR_IDS.has(receivedTrId);
|
||||
const isExpectedTick = EXPECTED_REALTIME_TRADE_TR_IDS.has(receivedTrId);
|
||||
// 체결 화면에는 "실제 체결 TR"만 반영하고 예상체결 TR은 제외합니다.
|
||||
if (!isExecutedTick || isExpectedTick) {
|
||||
return [] as DashboardRealtimeTradeTick[];
|
||||
}
|
||||
|
||||
@@ -88,18 +98,15 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
||||
return [] as DashboardRealtimeTradeTick[];
|
||||
}
|
||||
|
||||
const normalizedExpected = normalizeDomesticSymbol(expectedSymbol);
|
||||
const ticks: DashboardRealtimeTradeTick[] = [];
|
||||
|
||||
for (let index = 0; index < parsedCount; index++) {
|
||||
const base = index * fieldsPerTick;
|
||||
const symbol = readString(values, base + TICK_FIELD_INDEX.symbol);
|
||||
if (symbol !== expectedSymbol) {
|
||||
if (symbol.trim() !== expectedSymbol.trim()) {
|
||||
console.warn(
|
||||
`[KisRealtime] Symbol mismatch: received '${symbol}', expected '${expectedSymbol}'`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const normalizedSymbol = normalizeDomesticSymbol(symbol);
|
||||
if (normalizedSymbol !== normalizedExpected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const price = readNumber(values, base + TICK_FIELD_INDEX.price);
|
||||
@@ -119,7 +126,7 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
||||
: rawChangeRate;
|
||||
|
||||
ticks.push({
|
||||
symbol,
|
||||
symbol: normalizedExpected,
|
||||
tickTime: readString(values, base + TICK_FIELD_INDEX.tickTime),
|
||||
price,
|
||||
change,
|
||||
@@ -144,6 +151,10 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
||||
values,
|
||||
base + TICK_FIELD_INDEX.netBuyExecutionCount,
|
||||
),
|
||||
executionClassCode: readString(
|
||||
values,
|
||||
base + TICK_FIELD_INDEX.executionClassCode,
|
||||
),
|
||||
open: readNumber(values, base + TICK_FIELD_INDEX.open),
|
||||
high: readNumber(values, base + TICK_FIELD_INDEX.high),
|
||||
low: readNumber(values, base + TICK_FIELD_INDEX.low),
|
||||
|
||||
Reference in New Issue
Block a user