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; currentPrice?: number; latestTick: DashboardRealtimeTradeTick | null; recentTicks: DashboardRealtimeTradeTick[]; orderBook: DashboardStockOrderBookResponse | null; isLoading?: boolean; } interface BookRow { price: number; size: number; changePercent: 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; } /** 체결 시각 포맷 */ 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; } // ─── 메인 컴포넌트 ────────────────────────────────────── /** * 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다. */ export function OrderBook({ symbol, referencePrice, currentPrice, 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 = (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 && 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 (