/** * 실시간 호가 — 증권앱형 통합 레이아웃 * 상단 시세 · 서브탭 · 매도/현재가/매수 테이블 · 우측 시세 · 좌측 체결 · 하단 합계 */ import React, { memo, useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { formatNowClock, useDisplayTimezone } from '../utils/timezone'; import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook'; import type { RecentTrade } from '../hooks/useUpbitRecentTrades'; import { getKoreanName } from '../utils/marketNameCache'; import { formatUpbitKrwPrice } from '../utils/safeFormat'; function fmtPrice(p: number): string { if (p == null || !isFinite(p)) return '-'; return formatUpbitKrwPrice(p, '-'); } function fmtSize(s: number): string { if (s == null || !isFinite(s)) return '-'; if (s >= 1_000_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); if (s >= 1_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 2 }); if (s >= 1) return s.toFixed(4); return s.toFixed(6); } function fmtTradeVol(v: number): string { if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`; if (v >= 1_000) return Math.round(v).toLocaleString('ko-KR'); return v.toFixed(4); } function fmtPct(price: number, prevClose: number): string { if (!prevClose) return ''; const rate = ((price - prevClose) / prevClose) * 100; return `${rate >= 0 ? '+' : ''}${rate.toFixed(2)}%`; } function fmtTotalAmount(a: number): string { if (a == null || !isFinite(a)) return '-'; return a.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); } /** 하단 합계·호가 잔량 표시 모드 — 기본 총액(KRW) */ type SummaryDisplayMode = 'amount' | 'quantity'; function unitDisplayValue(unit: OrderbookDisplayUnit, mode: SummaryDisplayMode): number { return mode === 'amount' ? unit.price * unit.size : unit.size; } function formatDisplayValue(value: number, mode: SummaryDisplayMode): string { return mode === 'amount' ? fmtTotalAmount(value) : fmtSize(value); } function fmtTotalSize(s: number): string { if (s >= 1_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); if (s >= 1) return s.toFixed(2); return s.toFixed(4); } function SummaryBar({ asks, bids, totalAskSize, totalBidSize, clock, mode, onModeChange, }: { asks: OrderbookDisplayUnit[]; bids: OrderbookDisplayUnit[]; totalAskSize: number; totalBidSize: number; clock: string; mode: SummaryDisplayMode; onModeChange: (mode: SummaryDisplayMode) => void; }) { const [menuOpen, setMenuOpen] = useState(false); const wrapRef = useRef(null); const totalAskAmount = useMemo( () => asks.reduce((sum, u) => sum + u.price * u.size, 0), [asks], ); const totalBidAmount = useMemo( () => bids.reduce((sum, u) => sum + u.price * u.size, 0), [bids], ); const askDisplay = mode === 'amount' ? fmtTotalAmount(totalAskAmount) : fmtTotalSize(totalAskSize); const bidDisplay = mode === 'amount' ? fmtTotalAmount(totalBidAmount) : fmtTotalSize(totalBidSize); const closeMenu = useCallback(() => setMenuOpen(false), []); useEffect(() => { if (!menuOpen) return; const onDoc = (e: MouseEvent) => { if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { closeMenu(); } }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [menuOpen, closeMenu]); const pickMode = (next: SummaryDisplayMode) => { onModeChange(next); closeMenu(); }; return ( ); } function coinCode(market: string): string { return market.replace(/^KRW-/, ''); } /** 호가 가격과 현재가(체결가) 일치 여부 */ function isLivePrice(levelPrice: number, currentPrice: number): boolean { if (levelPrice <= 0 || currentPrice <= 0) return false; const tol = Math.max(1e-8, Math.abs(currentPrice) * 1e-6); return Math.abs(levelPrice - currentPrice) <= tol; } function pctClass(pct: string): string { if (pct.startsWith('+')) return 'ob-td-pct--up'; if (pct.startsWith('-')) return 'ob-td-pct--dn'; return ''; } function useClock(): string { const tz = useDisplayTimezone(); const [t, setT] = useState(() => formatNowClock(tz, true)); useEffect(() => { const tick = () => setT(formatNowClock(tz, true)); tick(); const id = setInterval(tick, 1000); return () => clearInterval(id); }, [tz]); return t; } const COLGROUP = ( ); const AskTableRow = memo(function AskTableRow({ unit, prevClose, displayMode, maxDisplay, currentPrice, onClick, }: { unit: OrderbookDisplayUnit; prevClose: number; displayMode: SummaryDisplayMode; maxDisplay: number; currentPrice: number; onClick?: (price: number, type: 'ask' | 'bid') => void; }) { const pct = fmtPct(unit.price, prevClose); const displayVal = unitDisplayValue(unit, displayMode); const barPct = maxDisplay > 0 ? (displayVal / maxDisplay) * 100 : 0; const isLive = isLivePrice(unit.price, currentPrice); return ( onClick(unit.price, 'ask') : undefined}>
{formatDisplayValue(displayVal, displayMode)}
{fmtPrice(unit.price)} {pct} ); }); /** 가격은 항상 가운데 열 — 매수: 전일대비(좌) | 가격 | 잔량(우) */ const BidTableRow = memo(function BidTableRow({ unit, prevClose, displayMode, maxDisplay, currentPrice, onClick, }: { unit: OrderbookDisplayUnit; prevClose: number; displayMode: SummaryDisplayMode; maxDisplay: number; currentPrice: number; onClick?: (price: number, type: 'ask' | 'bid') => void; }) { const pct = fmtPct(unit.price, prevClose); const displayVal = unitDisplayValue(unit, displayMode); const barPct = maxDisplay > 0 ? (displayVal / maxDisplay) * 100 : 0; const isLive = isLivePrice(unit.price, currentPrice); return ( onClick(unit.price, 'bid') : undefined}> {pct} {fmtPrice(unit.price)}
{formatDisplayValue(displayVal, displayMode)}
); }); function QuoteHeader({ market, tickerInfo, prevClose, totalAskSize, totalBidSize, wsStatus, }: { market: string; tickerInfo?: OrderbookTickerInfo; prevClose: number; totalAskSize: number; totalBidSize: number; wsStatus: WsStatus; }) { const price = tickerInfo?.tradePrice ?? 0; const change = price > 0 && prevClose > 0 ? price - prevClose : (tickerInfo?.changePrice ?? 0); const rate = tickerInfo?.changeRate != null ? tickerInfo.changeRate * 100 : (prevClose > 0 && price > 0 ? (change / prevClose) * 100 : 0); const isUp = change >= 0; const vol = tickerInfo?.accTradeVolume24 ?? 0; const bidRatio = totalAskSize + totalBidSize > 0 ? (totalBidSize / (totalAskSize + totalBidSize)) * 100 : 50; const code = coinCode(market); return (
{getKoreanName(market)}
{price > 0 ? fmtPrice(price) : '-'} {change >= 0 ? '+' : ''}{Math.abs(change) >= 1000 ? Math.round(change).toLocaleString('ko-KR') : change.toFixed(change >= 1 ? 0 : 2)} {rate >= 0 ? '+' : ''}{rate.toFixed(2)}%
거래량 {vol > 0 ? `${Math.round(vol).toLocaleString('ko-KR')} ${code}` : '-'}
매수비 {bidRatio.toFixed(2)}%
); } function WsDot({ status }: { status: WsStatus }) { const cls = status === 'connected' ? 'ob-dot--ok' : status === 'connecting' ? 'ob-dot--wait' : 'ob-dot--err'; return ; } function StatsRail({ tickerInfo, prevClose }: { tickerInfo: OrderbookTickerInfo; prevClose: number; }) { return ( ); } function TradesPanel({ trades, strength }: { trades: RecentTrade[]; strength: number | null }) { return ( ); } export interface OrderbookTickerInfo { tradePrice: number | null; changeRate: number | null; changePrice?: number | null; accTradePrice24: number; accTradeVolume24?: number | null; highPrice: number | null; lowPrice: number | null; openingPrice: number | null; } export interface OrderbookPanelProps { market: string; asks: OrderbookDisplayUnit[]; bids: OrderbookDisplayUnit[]; totalAskSize: number; totalBidSize: number; wsStatus: WsStatus; bestAsk: number; bestBid: number; spread: number; spreadPct: number; prevClose: number; tickerInfo?: OrderbookTickerInfo; recentTrades?: RecentTrade[]; tradeStrength?: number | null; onRowClick?: (price: number, rowType: 'ask' | 'bid') => void; } const SUB_TABS = ['호가', '체결', '일별', '차트'] as const; export const OrderbookPanel = memo(function OrderbookPanel({ market, asks, bids, totalAskSize, totalBidSize, wsStatus, prevClose, tickerInfo, recentTrades = [], tradeStrength = null, onRowClick, }: OrderbookPanelProps) { const isEmpty = asks.length === 0 && bids.length === 0; const currentPrice = tickerInfo?.tradePrice ?? 0; const clock = useClock(); const [summaryMode, setSummaryMode] = useState('amount'); const maxDisplay = useMemo(() => { const all = [...asks, ...bids]; if (all.length === 0) return 0; return Math.max(...all.map(u => unitDisplayValue(u, summaryMode)), 0); }, [asks, bids, summaryMode]); return (
{isEmpty ? (
호가 데이터 로딩 중...
) : (
{/* 매도 구간 + 우측 시세 */}
{COLGROUP} {asks.map((unit, i) => ( ))}
{tickerInfo && }
{/* 매수 구간 + 좌측 체결 */}
{COLGROUP} {bids.map((unit, i) => ( ))}
)} {!isEmpty && ( )}
); }); export default OrderbookPanel;