import { useEffect, useRef } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; import type { DashboardOrderBookLevel, DashboardRealtimeTradeTick, DashboardStockOrderBookResponse, } from "@/features/dashboard/types/dashboard.types"; import { cn } from "@/lib/utils"; const FIXED_DEPTH_COUNT = 10; interface OrderBookProps { symbol?: string; referencePrice?: number; currentPrice?: number; latestTick: DashboardRealtimeTradeTick | null; recentTicks: DashboardRealtimeTradeTick[]; orderBook: DashboardStockOrderBookResponse | null; isLoading?: boolean; } /** * @description 호가창(일반호가/누적호가/호가주문) UI * @see features/dashboard/hooks/useOrderBook.ts 초기호가 + 실시간호가 결합 */ export function OrderBook({ symbol, referencePrice, currentPrice, latestTick, recentTicks, orderBook, isLoading, }: OrderBookProps) { const prevAskSizeByPriceRef = useRef>(new Map()); const prevBidSizeByPriceRef = useRef>(new Map()); // TODO: 상위 컴포넌트에서 실제 연결 상태를 props로 전달하도록 개선 const isTradeConnected = !!orderBook; // ========== DERIVED STATE ========== const rawAsks = orderBook ? [...orderBook.levels].reverse() : []; const rawBids = orderBook ? orderBook.levels : []; // ========== FIXED DEPTH ROWS ========== const asks = toFixedDepthRows(rawAsks, FIXED_DEPTH_COUNT, "start"); const bids = toFixedDepthRows(rawBids, FIXED_DEPTH_COUNT, "end"); const bestAsk = findFirstPositive(asks, "askPrice"); const bestBid = findFirstPositive(bids, "bidPrice"); const spread = bestAsk > 0 && bestBid > 0 ? Math.max(bestAsk - bestBid, 0) : 0; const imbalance = calcImbalance( orderBook ? orderBook.totalBidSize : 0, orderBook ? orderBook.totalAskSize : 0, ); // 최근 체결가와 정확히 일치하는 호가만 강조 const highlightedPrice = resolveHighlightedPrice(latestTick?.price, asks, bids); const percentBasePrice = referencePrice && referencePrice > 0 ? referencePrice : currentPrice && currentPrice > 0 ? currentPrice : 0; // eslint-disable-next-line react-hooks/refs const prevAskSizeByPrice = prevAskSizeByPriceRef.current; // eslint-disable-next-line react-hooks/refs const prevBidSizeByPrice = prevBidSizeByPriceRef.current; // ========== PREV SNAPSHOT ========== useEffect(() => { if (!orderBook) { prevAskSizeByPriceRef.current = new Map(); prevBidSizeByPriceRef.current = new Map(); return; } const snapshotAsks = toFixedDepthRows( [...orderBook.levels].reverse(), FIXED_DEPTH_COUNT, "start", ); const snapshotBids = toFixedDepthRows( orderBook.levels, FIXED_DEPTH_COUNT, "end", ); const nextAskMap = new Map(); const nextBidMap = new Map(); snapshotAsks.forEach((row) => { if (row.askPrice > 0) { nextAskMap.set(row.askPrice, row.askSize); } }); snapshotBids.forEach((row) => { if (row.bidPrice > 0) { nextBidMap.set(row.bidPrice, row.bidSize); } }); prevAskSizeByPriceRef.current = nextAskMap; prevBidSizeByPriceRef.current = nextBidMap; }, [orderBook]); // ========== EMPTY STATE ========== if (!symbol) { return (
종목을 선택해 주세요.
); } if (isLoading && !orderBook) { return ; } if (!orderBook) { return (
호가 데이터가 없습니다.
); } return (
{/* ========== TAB HEADER ========== */}
일반호가 누적호가 호가주문
{/* ========== NORMAL TAB ========== */}
{/* ========== ORDER BOOK BODY ========== */}
{/* ========== TRADE TAPE ========== */}
{/* ========== CUMULATIVE TAB ========== */}
매도 누적 호가 매수 누적
{/* ========== ORDER TAB ========== */}
호가주문 탭은 주문 폼과 연동하여 다음 단계에서 확장합니다.
); } /** * @description 호가 테이블 머리글 * @see OrderBook 일반호가 탭 본문 */ function BookHeader() { return (
매도잔량
호가
매수잔량
); } /** * @description 매도/매수 호가 행 목록 * @see OrderBook 일반호가 탭 본문 */ function BookSideRows({ rows, side, maxSize, percentBasePrice, highlightedPrice, previousSizeByPrice, }: { rows: DashboardOrderBookLevel[]; side: "ask" | "bid"; maxSize: number; percentBasePrice: number; highlightedPrice: number; previousSizeByPrice: Map; }) { return (
{rows.map((row, index) => { const price = side === "ask" ? row.askPrice : row.bidPrice; const size = side === "ask" ? row.askSize : row.bidSize; const ratio = maxSize > 0 ? Math.min((size / maxSize) * 100, 100) : 0; const isHighlightedRow = highlightedPrice > 0 && price > 0 && price === highlightedPrice; const prevSize = price > 0 ? previousSizeByPrice.get(price) : undefined; const delta = prevSize === undefined ? null : size - prevSize; const deltaRate = delta === null ? null : prevSize !== undefined && prevSize > 0 ? (delta / prevSize) * 100 : delta > 0 ? 100 : 0; return (
{side === "ask" && ( <>
{formatNumber(size)}
)}
{price > 0 ? formatNumber(price) : "-"} 0 && percentBasePrice > 0 ? calcChangePercent(price, percentBasePrice) >= 0 ? "text-red-500" : "text-blue-500" : "text-muted-foreground", )} > {price > 0 && percentBasePrice > 0 ? formatSignedPercent(calcChangePercent(price, percentBasePrice)) : "-"} {isHighlightedRow && ( 현재가 )}
{side === "bid" && ( <>
{formatNumber(size)}
)}
); })}
); } /** * @description 잔량 증감 표시(절대값 + 증감률) * @see BookSideRows 수량 변경 시 +/-% 표시 */ function SizeDeltaIndicator({ delta, deltaRate, }: { delta: number | null; deltaRate: number | null; }) { if (delta === null || delta === 0 || deltaRate === null) { return -; } return ( 0 ? "text-red-500" : "text-blue-500", )} > {formatSignedNumber(delta)} ({formatSignedPercent(deltaRate)}) ); } /** * @description 호가 중앙 요약(스프레드/체결강도/수급) * @see OrderBook 일반호가 탭 본문 */ function BookCenterRow({ bestAsk, bestBid, spread, imbalance, latestTick, }: { bestAsk: number; bestBid: number; spread: number; imbalance: number; latestTick: DashboardRealtimeTradeTick | null; }) { return (
{bestAsk > 0 ? `매도1 ${formatNumber(bestAsk)}` : "-"}
스프레드 {formatNumber(spread)}
{bestBid > 0 ? `매수1 ${formatNumber(bestBid)}` : "-"}
체결강도 {latestTick ? `${latestTick.tradeStrength.toFixed(2)}%` : "-"}
수급 불균형 {imbalance >= 0 ? "+" : ""} {imbalance.toFixed(2)}%
체결량 {latestTick ? formatNumber(latestTick.tradeVolume) : "-"}
); } function BookRealtimeSummary({ orderBook, latestTick, spread, imbalance, totalAskSize, totalBidSize, isTradeConnected, }: { orderBook: { accumulatedVolume?: number; anticipatedVolume?: number; anticipatedPrice?: number; anticipatedChangeRate?: number; }; latestTick: DashboardRealtimeTradeTick | null; spread: number; imbalance: number; totalAskSize: number; totalBidSize: number; isTradeConnected: boolean; }) { return (
{/* ========== WS STATUS ========== */}
실시간 {isTradeConnected ? "연결됨" : "연결 대기"}
= 0 ? "+" : ""}${imbalance.toFixed(2)}%`} tone={imbalance >= 0 ? "bid" : "ask"} />
); } function SummaryItem({ label, value, tone, }: { label: string; value: string; tone?: "ask" | "bid"; }) { return (
{label} {value}
); } /** * @description 체결 테이프 * @see features/dashboard/hooks/useKisTradeWebSocket.ts recentTradeTicks */ function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) { return (
{/* ========== TRADE TAPE HEADER ========== */}
체결시각
체결가
체결량
체결강도
{/* ========== TRADE TAPE ROWS ========== */}
{ticks.length === 0 && (
체결 데이터 수신 대기 중입니다.
)} {ticks.map((tick, index) => (
{formatTickTime(tick.tickTime)}
{formatNumber(tick.price)}
{formatNumber(tick.tradeVolume)}
{tick.tradeStrength.toFixed(2)}%
))}
); } /** * @description 누적호가 행 계산 * @see OrderBook 누적호가 탭 */ function CumulativeRows({ asks, bids, }: { asks: DashboardOrderBookLevel[]; bids: DashboardOrderBookLevel[]; }) { const maxLen = Math.max(asks.length, bids.length); const cumulativeRows: Array<{ key: string; askAcc: number; bidAcc: number; price: number; }> = []; let askAcc = 0; let bidAcc = 0; for (let index = 0; index < maxLen; index += 1) { const askRow = asks[index]; const bidRow = bids[index]; askAcc += askRow?.askSize ?? 0; bidAcc += bidRow?.bidSize ?? 0; cumulativeRows.push({ key: `acc-${index}`, askAcc, bidAcc, price: askRow?.askPrice || bidRow?.bidPrice || 0, }); } return (
{cumulativeRows.map((row) => (
{formatNumber(row.askAcc)} {formatNumber(row.price)} {formatNumber(row.bidAcc)}
))}
); } function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) { return (
); } function OrderBookSkeleton() { return (
{Array.from({ length: 16 }).map((_, i) => ( ))}
); } function findFirstPositive( rows: DashboardOrderBookLevel[], key: "askPrice" | "bidPrice", ) { const row = rows.find((item) => item[key] > 0); return row?.[key] ?? 0; } function calcImbalance(totalBid: number, totalAsk: number) { const sum = totalBid + totalAsk; if (sum === 0) return 0; return ((totalBid - totalAsk) / sum) * 100; } /** * @description 호가 행 개수를 고정(기본 10)하여 UI 흔들림을 방지합니다. * @see OrderBook 상단/하단 호가 행 고정 렌더링 */ function toFixedDepthRows( rows: DashboardOrderBookLevel[], targetCount: number, padDirection: "start" | "end", ) { if (rows.length === targetCount) return rows; if (rows.length > targetCount) { return rows.slice(0, targetCount); } const emptyRows = Array.from({ length: targetCount - rows.length }, () => ({ askPrice: 0, bidPrice: 0, askSize: 0, bidSize: 0, })); return padDirection === "start" ? [...emptyRows, ...rows] : [...rows, ...emptyRows]; } function resolveHighlightedPrice( latestTickPrice: number | undefined, asks: DashboardOrderBookLevel[], bids: DashboardOrderBookLevel[], ) { const preferred = latestTickPrice && latestTickPrice > 0 ? latestTickPrice : 0; if (preferred <= 0) return 0; const candidates = [ ...asks.map((row) => row.askPrice), ...bids.map((row) => row.bidPrice), ].filter((price) => price > 0); if (candidates.length === 0) return 0; return candidates.includes(preferred) ? preferred : 0; } function formatTickTime(hhmmss: string) { if (!hhmmss || hhmmss.length !== 6) return "--:--:--"; return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`; } function formatNumber(value: number) { return Number.isFinite(value) ? value.toLocaleString("ko-KR") : "0"; } function formatSignedNumber(value: number) { const sign = value > 0 ? "+" : ""; return `${sign}${value.toLocaleString("ko-KR")}`; } function calcChangePercent(price: number, basePrice: number) { if (basePrice <= 0) return 0; return ((price - basePrice) / basePrice) * 100; } function formatSignedPercent(value: number) { const sign = value > 0 ? "+" : ""; return `${sign}${value.toFixed(2)}%`; }