import { useMemo } 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 { DashboardRealtimeTradeTick, DashboardStockOrderBookResponse, } from "@/features/dashboard/types/dashboard.types"; import { cn } from "@/lib/utils"; import { AnimatedQuantity } from "./AnimatedQuantity"; // ─── 타입 ─────────────────────────────────────────────── interface OrderBookProps { symbol?: string; referencePrice?: number; currentPrice?: number; latestTick: DashboardRealtimeTradeTick | null; recentTicks: DashboardRealtimeTradeTick[]; orderBook: DashboardStockOrderBookResponse | null; isLoading?: boolean; } interface BookRow { price: number; size: number; changePercent: number | null; isHighlighted: boolean; } // ─── 유틸리티 함수 ────────────────────────────────────── /** 천단위 구분 포맷 */ function fmt(v: number) { return Number.isFinite(v) ? v.toLocaleString("ko-KR") : "0"; } /** 부호 포함 퍼센트 */ function fmtPct(v: number) { return `${v > 0 ? "+" : ""}${v.toFixed(2)}%`; } /** 등락률 계산 */ function pctChange(price: number, base: number) { return base > 0 ? ((price - base) / base) * 100 : 0; } /** 체결 시각 포맷 */ function fmtTime(hms: string) { if (!hms || hms.length !== 6) return "--:--:--"; return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`; } // ─── 메인 컴포넌트 ────────────────────────────────────── /** * 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다. */ export function OrderBook({ symbol, referencePrice, currentPrice, latestTick, recentTicks, orderBook, isLoading, }: OrderBookProps) { const levels = useMemo(() => orderBook?.levels ?? [], [orderBook]); // 체결가: tick에서 우선, 없으면 0 const latestPrice = latestTick?.price && latestTick.price > 0 ? latestTick.price : 0; // 등락률 기준가 const basePrice = (referencePrice ?? 0) > 0 ? referencePrice! : (currentPrice ?? 0) > 0 ? currentPrice! : latestPrice > 0 ? latestPrice : 0; // 매도호가 (역순: 10호가 → 1호가) const askRows: BookRow[] = useMemo( () => [...levels].reverse().map((l) => ({ price: l.askPrice, size: Math.max(l.askSize, 0), changePercent: l.askPrice > 0 && basePrice > 0 ? pctChange(l.askPrice, basePrice) : null, isHighlighted: latestPrice > 0 && l.askPrice === latestPrice, })), [levels, basePrice, latestPrice], ); // 매수호가 (1호가 → 10호가) const bidRows: BookRow[] = useMemo( () => levels.map((l) => ({ price: l.bidPrice, size: Math.max(l.bidSize, 0), changePercent: l.bidPrice > 0 && basePrice > 0 ? pctChange(l.bidPrice, basePrice) : null, isHighlighted: latestPrice > 0 && l.bidPrice === latestPrice, })), [levels, basePrice, latestPrice], ); const askMax = Math.max(1, ...askRows.map((r) => r.size)); const bidMax = Math.max(1, ...bidRows.map((r) => r.size)); // 스프레드·수급 불균형 const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0; const bestBid = levels.find((l) => l.bidPrice > 0)?.bidPrice ?? 0; const spread = bestAsk > 0 && bestBid > 0 ? bestAsk - bestBid : 0; const totalAsk = orderBook?.totalAskSize ?? 0; const totalBid = orderBook?.totalBidSize ?? 0; const imbalance = totalAsk + totalBid > 0 ? ((totalBid - totalAsk) / (totalAsk + totalBid)) * 100 : 0; // 체결가 행 중앙 스크롤 // ─── 빈/로딩 상태 ─── if (!symbol) { return (
종목을 선택해주세요.
); } if (isLoading && !orderBook) return ; if (!orderBook) { return (
호가 정보를 가져오지 못했습니다.
); } return (
{/* 탭 헤더 */}
일반호가 누적호가 호가주문
{/* ── 일반호가 탭 ── */}
{/* 호가 테이블 */}
{/* 매도호가 */} {/* 중앙 바: 현재 체결가 */}
{totalAsk > 0 ? fmt(totalAsk) : ""}
{latestPrice > 0 ? fmt(latestPrice) : bestAsk > 0 ? fmt(bestAsk) : "-"} {latestPrice > 0 && basePrice > 0 && ( = basePrice ? "text-red-500" : "text-blue-600 dark:text-blue-400", )} > {fmtPct(pctChange(latestPrice, basePrice))} )}
{totalBid > 0 ? fmt(totalBid) : ""}
{/* 매수호가 */}
{/* 우측 요약 패널 */}
{/* 체결 목록 */}
{/* ── 누적호가 탭 ── */}
매도누적 호가 매수누적
{/* ── 호가주문 탭 ── */}
호가주문 탭은 주문 입력 패널과 연동해 확장할 수 있습니다.
); } // ─── 하위 컴포넌트 ────────────────────────────────────── /** 호가 표 헤더 */ function BookHeader() { return (
매도잔량
호가
매수잔량
); } /** 매도 또는 매수 호가 행 목록 */ function BookSideRows({ rows, side, maxSize, }: { rows: BookRow[]; side: "ask" | "bid"; maxSize: number; }) { const isAsk = side === "ask"; return (
{rows.map((row, i) => { const ratio = maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0; return (
{/* 매도잔량 (좌측) */}
{isAsk && ( <> )}
{/* 호가 (중앙) */}
{row.price > 0 ? fmt(row.price) : "-"} = 0 ? "text-red-500" : "text-blue-600 dark:text-blue-400" : "text-muted-foreground", )} > {row.changePercent === null ? "-" : fmtPct(row.changePercent)}
{/* 매수잔량 (우측) */}
{!isAsk && ( <> )}
); })}
); } /** 우측 요약 패널 */ function SummaryPanel({ orderBook, latestTick, spread, imbalance, totalAsk, totalBid, }: { orderBook: DashboardStockOrderBookResponse; latestTick: DashboardRealtimeTradeTick | null; spread: number; imbalance: number; totalAsk: number; totalBid: number; }) { return (
= 0 ? "+" : ""}${imbalance.toFixed(2)}%`} tone={imbalance >= 0 ? "bid" : "ask"} />
); } /** 요약 패널 단일 행 */ function Row({ label, value, tone, }: { label: string; value: string; tone?: "ask" | "bid"; }) { return (
{label} {value}
); } /** 잔량 깊이 바 */ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) { if (ratio <= 0) return null; return (
); } /** 체결 목록 (Trade Tape) */ function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) { return (
체결시각
체결가
체결량
체결강도
{ticks.length === 0 && (
체결 데이터가 아직 없습니다.
)} {ticks.map((t, i) => (
{fmtTime(t.tickTime)}
{fmt(t.price)}
{fmt(t.tradeVolume)}
{t.tradeStrength.toFixed(2)}%
))}
); } /** 누적호가 행 */ function CumulativeRows({ asks, bids }: { asks: BookRow[]; bids: BookRow[] }) { const rows = useMemo(() => { const len = Math.max(asks.length, bids.length); const result: { askAcc: number; bidAcc: number; price: number }[] = []; for (let i = 0; i < len; i++) { const prevAsk = result[i - 1]?.askAcc ?? 0; const prevBid = result[i - 1]?.bidAcc ?? 0; result.push({ askAcc: prevAsk + (asks[i]?.size ?? 0), bidAcc: prevBid + (bids[i]?.size ?? 0), price: asks[i]?.price || bids[i]?.price || 0, }); } return result; }, [asks, bids]); return (
{rows.map((r, i) => (
{fmt(r.askAcc)} {fmt(r.price)} {fmt(r.bidAcc)}
))}
); } /** 로딩 스켈레톤 */ function OrderBookSkeleton() { return (
{Array.from({ length: 16 }).map((_, i) => ( ))}
); }