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/trade/types/trade.types"; import { cn } from "@/lib/utils"; import { AnimatedQuantity } from "./AnimatedQuantity"; // ─── 타입 ─────────────────────────────────────────────── interface OrderBookProps { symbol?: string; referencePrice?: number; latestTick: DashboardRealtimeTradeTick | null; recentTicks: DashboardRealtimeTradeTick[]; orderBook: DashboardStockOrderBookResponse | null; isLoading?: boolean; } interface BookRow { price: number; size: number; changeValue: number | null; isHighlighted: boolean; } /** * @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다. * @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다. */ function hasOrderBookLevelData( levels: DashboardStockOrderBookResponse["levels"], ) { return levels.some( (level) => level.askPrice > 0 || level.bidPrice > 0 || level.askSize > 0 || level.bidSize > 0, ); } /** * @description H0STOAA0 미수신 상황에서 체결(H0UNCNT0) 데이터로 최소 1호가를 구성합니다. * @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeTickBatch 체결 데이터에서 1호가/잔량/총잔량을 파싱합니다. */ function buildFallbackLevelsFromTick( latestTick: DashboardRealtimeTradeTick | null, ) { if (!latestTick) return [] as DashboardStockOrderBookResponse["levels"]; if (latestTick.askPrice1 <= 0 && latestTick.bidPrice1 <= 0) { return [] as DashboardStockOrderBookResponse["levels"]; } return [ { askPrice: latestTick.askPrice1, bidPrice: latestTick.bidPrice1, askSize: Math.max(latestTick.askSize1, 0), bidSize: Math.max(latestTick.bidSize1, 0), }, ]; } // ─── 유틸리티 함수 ────────────────────────────────────── /** 천단위 구분 포맷 */ 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; } /** * @description 기준가 대비 증감값/증감률을 함께 계산합니다. * @see features/trade/components/orderbook/OrderBook.tsx buildBookRows */ function resolvePriceChange(price: number, basePrice: number) { if (price <= 0 || basePrice <= 0) { return { changeValue: null } as const; } const changeValue = price - basePrice; return { changeValue } as const; } /** * @description 증감 숫자를 부호 포함 문자열로 포맷합니다. * @see features/trade/components/orderbook/OrderBook.tsx BookSideRows */ function fmtSignedChange(v: number) { if (!Number.isFinite(v)) return "-"; if (v > 0) return `+${fmt(v)}`; if (v < 0) return `-${fmt(Math.abs(v))}`; return "0"; } /** * @description 증감값에 따라 색상 톤 클래스를 반환합니다. * @see features/trade/components/orderbook/OrderBook.tsx BookSideRows */ function getChangeToneClass( changeValue: number | null, neutralClass = "text-muted-foreground", ) { if (changeValue === null) { return neutralClass; } if (changeValue > 0) { return "text-red-500"; } if (changeValue < 0) { return "text-blue-600 dark:text-blue-400"; } return neutralClass; } /** 체결 시각 포맷 */ function fmtTime(hms: string) { if (!hms || hms.length !== 6) return "--:--:--"; 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; } /** * @description 호가 레벨을 화면 렌더링용 행 모델로 변환합니다. * UI 흐름: OrderBook(levels/basePrice/latestPrice) -> buildBookRows -> BookSideRows -> 가격/증감 반영 * @see features/trade/components/orderbook/OrderBook.tsx OrderBook askRows/bidRows 계산 */ function buildBookRows({ levels, side, basePrice, latestPrice, }: { levels: DashboardStockOrderBookResponse["levels"]; side: "ask" | "bid"; basePrice: number; latestPrice: number; }) { const normalizedLevels = side === "ask" ? [...levels].reverse() : levels; return normalizedLevels.map((level) => { const price = side === "ask" ? level.askPrice : level.bidPrice; const size = side === "ask" ? level.askSize : level.bidSize; const { changeValue } = resolvePriceChange(price, basePrice); return { price, size: Math.max(size, 0), changeValue, isHighlighted: latestPrice > 0 && price === latestPrice, } satisfies BookRow; }); } /** * @description 호가/체결 증감 표시용 기준가(전일종가)를 결정합니다. * @summary UI 흐름: OrderBook props -> resolveReferencePrice -> 호가행/체결가 증감 계산 반영 * @see features/trade/components/orderbook/OrderBook.tsx basePrice 계산 */ function resolveReferencePrice({ referencePrice, latestTick, }: { referencePrice?: number; latestTick: DashboardRealtimeTradeTick | null; }) { if ((referencePrice ?? 0) > 0) { return referencePrice!; } // referencePrice 미전달 케이스에서도 틱 데이터(price-change)로 전일종가를 역산합니다. if (latestTick?.price && Number.isFinite(latestTick.change)) { const derivedPrevClose = latestTick.price - latestTick.change; if (derivedPrevClose > 0) { return derivedPrevClose; } } return 0; } // ─── 메인 컴포넌트 ────────────────────────────────────── /** * 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다. */ export function OrderBook({ symbol, referencePrice, latestTick, recentTicks, orderBook, isLoading, }: OrderBookProps) { const realtimeLevels = useMemo(() => orderBook?.levels ?? [], [orderBook]); const fallbackLevelsFromTick = useMemo( () => buildFallbackLevelsFromTick(latestTick), [latestTick], ); const levels = useMemo(() => { if (hasOrderBookLevelData(realtimeLevels)) return realtimeLevels; return fallbackLevelsFromTick; }, [fallbackLevelsFromTick, realtimeLevels]); const isTickFallbackActive = !hasOrderBookLevelData(realtimeLevels) && fallbackLevelsFromTick.length > 0; // 체결가: tick에서 우선, 없으면 0 const latestPrice = latestTick?.price && latestTick.price > 0 ? latestTick.price : 0; // 등락률 기준가 const basePrice = resolveReferencePrice({ referencePrice, latestTick }); // 매도호가 (역순: 10호가 → 1호가) const askRows: BookRow[] = useMemo( () => buildBookRows({ levels, side: "ask", basePrice, latestPrice, }), [levels, basePrice, latestPrice], ); // 매수호가 (1호가 → 10호가) const bidRows: BookRow[] = useMemo( () => buildBookRows({ levels, side: "bid", basePrice, 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 mobileAskRows = useMemo(() => askRows.slice(0, 6), [askRows]); const mobileBidRows = useMemo(() => bidRows.slice(0, 6), [bidRows]); // 스프레드·수급 불균형 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 && orderBook.totalAskSize > 0 ? orderBook.totalAskSize : (latestTick?.totalAskSize ?? 0); const totalBid = orderBook?.totalBidSize && orderBook.totalBidSize > 0 ? orderBook.totalBidSize : (latestTick?.totalBidSize ?? 0); const imbalance = totalAsk + totalBid > 0 ? ((totalBid - totalAsk) / (totalAsk + totalBid)) * 100 : 0; // 체결가 행 중앙 스크롤 // ─── 빈/로딩 상태 ─── if (!symbol) { return (
종목을 선택해주세요.
); } if (isLoading && !orderBook && fallbackLevelsFromTick.length === 0) { return ; } if (!orderBook && fallbackLevelsFromTick.length === 0) { return (
호가 정보를 가져오지 못했습니다.
); } return (
{/* 탭 헤더 */}
일반호가 누적호가 호가주문
{/* ── 일반호가 탭 ── */}
{/* 호가 테이블 */}
{isTickFallbackActive && (
시간외 전용 호가(`H0STOAA0`) 미수신 상태입니다. 체결(`H0UNCNT0`) 1호가 기준으로 표시 중입니다.
)}
{/* 모바일: 양방향 호가가 항상 보이도록 6호가씩 고정 노출 */}
{/* 데스크톱: 전체 호가 스크롤 */}
{/* 체결량 영역 */}
{/* 실시간 정보 영역 */}
{/* ── 누적호가 탭 ── */}
매도누적 호가 매수누적
{/* ── 호가주문 탭 ── */}
호가주문 탭은 주문 입력 패널과 연동해 확장할 수 있습니다.
); } // ─── 하위 컴포넌트 ────────────────────────────────────── /** * @description 호가창 중앙의 현재가/등락률/총잔량 바를 렌더링합니다. * @summary UI 흐름: OrderBook -> CurrentPriceBar -> 매도/매수 구간 사이 현재 체결 상태를 강조 표시 * @see features/trade/components/orderbook/OrderBook.tsx 일반호가 탭의 모바일/데스크톱 공통 현재가 행 */ function CurrentPriceBar({ latestPrice, basePrice, bestAsk, totalAsk, totalBid, }: { latestPrice: number; basePrice: number; bestAsk: number; totalAsk: number; totalBid: number; }) { return (
{totalAsk > 0 ? fmt(totalAsk) : ""}
0 && basePrice > 0 ? latestPrice >= basePrice ? "text-red-600" : "text-blue-600 dark:text-blue-400" : "text-foreground dark:text-brand-50", )} > {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.size > 0 ? ( ) : ( 0 )} )}
{/* 호가 (중앙) */}
{row.price > 0 ? fmt(row.price) : "-"} {row.changeValue === null ? "-" : fmtSignedChange(row.changeValue)}
{/* 매수잔량 (우측) */}
{!isAsk && ( <> {row.size > 0 ? ( ) : ( 0 )} )}
); })}
); } /** 우측 요약 패널 */ function SummaryPanel({ orderBook, latestTick, spread, imbalance, totalAsk, totalBid, }: { orderBook: DashboardStockOrderBookResponse | null; latestTick: DashboardRealtimeTradeTick | null; spread: number; imbalance: number; totalAsk: number; totalBid: number; }) { const displayTradeVolume = latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0 ? (orderBook?.anticipatedVolume ?? 0) : (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0); const summaryItems: SummaryMetric[] = [ { label: "실시간", value: orderBook || latestTick ? "연결됨" : "끊김", tone: orderBook || latestTick ? "bid" : undefined, }, { label: "거래량", value: fmt(displayTradeVolume) }, { label: "누적거래량", value: fmt( latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0, ), }, { label: "체결강도", value: latestTick ? `${latestTick.tradeStrength.toFixed(2)}%` : orderBook?.anticipatedChangeRate !== undefined ? `${orderBook.anticipatedChangeRate.toFixed(2)}%` : "-", }, { label: "예상체결가", value: fmt(orderBook?.anticipatedPrice ?? 0) }, { label: "매도1호가", value: latestTick ? fmt(latestTick.askPrice1) : "-", tone: "ask", }, { label: "매수1호가", value: latestTick ? fmt(latestTick.bidPrice1) : "-", tone: "bid", }, { label: "순매수체결", value: latestTick ? fmt(latestTick.netBuyExecutionCount) : "-", }, { label: "총 매도잔량", value: fmt(totalAsk), tone: "ask" }, { label: "총 매수잔량", value: fmt(totalBid), tone: "bid" }, { label: "스프레드", value: fmt(spread) }, { label: "수급 불균형", value: `${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`, tone: imbalance >= 0 ? "bid" : "ask", }, ]; return (
{summaryItems.map((item) => ( ))}
); } interface SummaryMetric { label: string; value: string; tone?: "ask" | "bid"; } /** * @description 실시간 요약 패널의 2열 메트릭 셀을 렌더링합니다. * @summary UI 흐름: SummaryPanel -> SummaryMetricCell -> 체결량 하단 정보 패널을 압축해 스크롤 없이 표시 * @see features/trade/components/orderbook/OrderBook.tsx SummaryPanel summaryItems */ function SummaryMetricCell({ 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, maxRows, }: { ticks: DashboardRealtimeTradeTick[]; maxRows?: number; }) { const visibleTicks = typeof maxRows === "number" ? ticks.slice(0, maxRows) : ticks; const shouldUseScrollableList = typeof maxRows !== "number"; const tapeRows = (
{visibleTicks.length === 0 && (
체결 데이터가 아직 없습니다.
)} {visibleTicks.map((t, i) => { const olderTick = visibleTicks[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 (
{fmtTime(t.tickTime)}
{fmt(t.price)}
{fmt(t.tradeVolume)}
); })}
); return (
체결시각
체결가
체결량
{shouldUseScrollableList ? ( {tapeRows} ) : ( tapeRows )}
); } /** 누적호가 행 */ 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) => ( ))}
); }