503 lines
18 KiB
TypeScript
503 lines
18 KiB
TypeScript
/**
|
|
* 실시간 호가 — 증권앱형 통합 레이아웃
|
|
* 상단 시세 · 서브탭 · 매도/현재가/매수 테이블 · 우측 시세 · 좌측 체결 · 하단 합계
|
|
*/
|
|
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<HTMLDivElement>(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 (
|
|
<footer className="ob-summary-bar">
|
|
<div className="ob-summary ob-summary--ask">
|
|
<span className="ob-summary-num">{askDisplay}</span>
|
|
</div>
|
|
<div className="ob-summary ob-summary--time ob-summary-time-wrap" ref={wrapRef}>
|
|
<button
|
|
type="button"
|
|
className="ob-summary-time-btn"
|
|
aria-haspopup="menu"
|
|
aria-expanded={menuOpen}
|
|
onClick={() => setMenuOpen(o => !o)}
|
|
>
|
|
<span>{clock}</span>
|
|
<span className="ob-summary-time-caret" aria-hidden>▾</span>
|
|
</button>
|
|
{menuOpen && (
|
|
<div className="ob-summary-mode-menu" role="menu">
|
|
<button
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked={mode === 'quantity'}
|
|
className={`ob-summary-mode-item${mode === 'quantity' ? ' ob-summary-mode-item--active' : ''}`}
|
|
onClick={() => pickMode('quantity')}
|
|
>
|
|
수량
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked={mode === 'amount'}
|
|
className={`ob-summary-mode-item${mode === 'amount' ? ' ob-summary-mode-item--active' : ''}`}
|
|
onClick={() => pickMode('amount')}
|
|
>
|
|
총액
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="ob-summary ob-summary--bid">
|
|
<span className="ob-summary-num">{bidDisplay}</span>
|
|
</div>
|
|
</footer>
|
|
);
|
|
}
|
|
|
|
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 = (
|
|
<colgroup>
|
|
<col className="ob-col-side" />
|
|
<col className="ob-col-price" />
|
|
<col className="ob-col-side" />
|
|
</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 (
|
|
<tr className={onClick ? 'ob-tr--clickable' : undefined} onClick={onClick ? () => onClick(unit.price, 'ask') : undefined}>
|
|
<td className="ob-td ob-td-qty ob-td-qty--ask">
|
|
<div className="ob-td-bar-wrap">
|
|
<div className="ob-td-bar ob-td-bar--ask" style={{ width: `${Math.min(barPct, 100)}%` }} />
|
|
<span className="ob-td-bar-text">{formatDisplayValue(displayVal, displayMode)}</span>
|
|
</div>
|
|
</td>
|
|
<td className={`ob-td ob-td-price${isLive ? ' ob-td-price--live' : ''}`}>{fmtPrice(unit.price)}</td>
|
|
<td className={`ob-td ob-td-pct ${pctClass(pct)}`}>{pct}</td>
|
|
</tr>
|
|
);
|
|
});
|
|
|
|
/** 가격은 항상 가운데 열 — 매수: 전일대비(좌) | 가격 | 잔량(우) */
|
|
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 (
|
|
<tr className={onClick ? 'ob-tr--clickable' : undefined} onClick={onClick ? () => onClick(unit.price, 'bid') : undefined}>
|
|
<td className={`ob-td ob-td-pct ob-td-pct--left ${pctClass(pct)}`}>{pct}</td>
|
|
<td className={`ob-td ob-td-price${isLive ? ' ob-td-price--live' : ''}`}>{fmtPrice(unit.price)}</td>
|
|
<td className="ob-td ob-td-qty ob-td-qty--bid">
|
|
<div className="ob-td-bar-wrap ob-td-bar-wrap--right">
|
|
<div className="ob-td-bar ob-td-bar--bid" style={{ width: `${Math.min(barPct, 100)}%` }} />
|
|
<span className="ob-td-bar-text">{formatDisplayValue(displayVal, displayMode)}</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
});
|
|
|
|
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 (
|
|
<div className="ob-quote-header">
|
|
<div className="ob-quote-left">
|
|
<span className="ob-quote-name">{getKoreanName(market)}</span>
|
|
<div className="ob-quote-price-row">
|
|
<span className={`ob-quote-price ${isUp ? 'ob-quote-price--up' : 'ob-quote-price--dn'}`}>
|
|
{price > 0 ? fmtPrice(price) : '-'}
|
|
</span>
|
|
<span className={`ob-quote-change ${isUp ? 'ob-quote-change--up' : 'ob-quote-change--dn'}`}>
|
|
{change >= 0 ? '+' : ''}{Math.abs(change) >= 1000
|
|
? Math.round(change).toLocaleString('ko-KR')
|
|
: change.toFixed(change >= 1 ? 0 : 2)}
|
|
</span>
|
|
<span className={`ob-quote-rate ${isUp ? 'ob-quote-rate--up' : 'ob-quote-rate--dn'}`}>
|
|
{rate >= 0 ? '+' : ''}{rate.toFixed(2)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="ob-quote-right">
|
|
<div className="ob-quote-vol">
|
|
<span className="ob-quote-vol-label">거래량</span>
|
|
<span className="ob-quote-vol-val">
|
|
{vol > 0 ? `${Math.round(vol).toLocaleString('ko-KR')} ${code}` : '-'}
|
|
</span>
|
|
</div>
|
|
<div className="ob-quote-ratio">
|
|
<span className="ob-quote-ratio-label">매수비</span>
|
|
<span className="ob-quote-ratio-val ob-quote-ratio-val--up">{bidRatio.toFixed(2)}%</span>
|
|
</div>
|
|
<WsDot status={wsStatus} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WsDot({ status }: { status: WsStatus }) {
|
|
const cls = status === 'connected' ? 'ob-dot--ok'
|
|
: status === 'connecting' ? 'ob-dot--wait' : 'ob-dot--err';
|
|
return <span className={`ob-dot ${cls}`}><span className="ob-dot-circle" /></span>;
|
|
}
|
|
|
|
function StatsRail({ tickerInfo, prevClose }: {
|
|
tickerInfo: OrderbookTickerInfo;
|
|
prevClose: number;
|
|
}) {
|
|
return (
|
|
<aside className="ob-stats-rail">
|
|
<div className="ob-stat-row"><span className="ob-stat-k">전일</span><span className="ob-stat-v">{prevClose > 0 ? fmtPrice(prevClose) : '-'}</span></div>
|
|
<div className="ob-stat-row"><span className="ob-stat-k">시가</span><span className="ob-stat-v">{tickerInfo.openingPrice != null ? fmtPrice(tickerInfo.openingPrice) : '-'}</span></div>
|
|
<div className="ob-stat-row"><span className="ob-stat-k">고가</span><span className="ob-stat-v ob-stat-v--up">{tickerInfo.highPrice != null ? fmtPrice(tickerInfo.highPrice) : '-'}</span></div>
|
|
<div className="ob-stat-row"><span className="ob-stat-k">저가</span><span className="ob-stat-v ob-stat-v--dn">{tickerInfo.lowPrice != null ? fmtPrice(tickerInfo.lowPrice) : '-'}</span></div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function TradesPanel({ trades, strength }: { trades: RecentTrade[]; strength: number | null }) {
|
|
return (
|
|
<aside className="ob-trades-panel">
|
|
<div className="ob-trades-strength">
|
|
<span className="ob-trades-strength-k">체결강도</span>
|
|
<span className={`ob-trades-strength-v${strength != null && strength >= 100 ? ' ob-trades-strength-v--up' : ''}`}>
|
|
{strength != null ? `${strength >= 100 ? '+' : ''}${strength.toFixed(2)}%` : '-'}
|
|
</span>
|
|
</div>
|
|
<table className="ob-table ob-table--trades">
|
|
<thead><tr><th>체결가</th><th>체결량</th></tr></thead>
|
|
<tbody>
|
|
{trades.length === 0 ? (
|
|
<tr><td colSpan={2} className="ob-td-empty">—</td></tr>
|
|
) : trades.map((t, i) => (
|
|
<tr key={`${t.time}-${i}`}>
|
|
<td className="ob-td ob-td-price-sm">{fmtPrice(t.price)}</td>
|
|
<td className={t.side === 'bid' ? 'ob-td ob-trades-vol--buy' : 'ob-td ob-trades-vol--sell'}>
|
|
{fmtTradeVol(t.volume)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
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<SummaryDisplayMode>('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 (
|
|
<div className="ob-panel ob-panel--classic">
|
|
<QuoteHeader
|
|
market={market}
|
|
tickerInfo={tickerInfo}
|
|
prevClose={prevClose}
|
|
totalAskSize={totalAskSize}
|
|
totalBidSize={totalBidSize}
|
|
wsStatus={wsStatus}
|
|
/>
|
|
|
|
<nav className="ob-subtabs" aria-label="호가 하위 메뉴">
|
|
{SUB_TABS.map((label, i) => (
|
|
<button
|
|
key={label}
|
|
type="button"
|
|
className={`ob-subtab${i === 0 ? ' ob-subtab--active' : ''}`}
|
|
disabled={i !== 0}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
{isEmpty ? (
|
|
<div className="ob-empty">
|
|
<div className="loading-spinner" style={{ width: 16, height: 16 }} />
|
|
<span>호가 데이터 로딩 중...</span>
|
|
</div>
|
|
) : (
|
|
<div className="ob-body">
|
|
{/* 매도 구간 + 우측 시세 */}
|
|
<div className="ob-block ob-block--ask">
|
|
<div className="ob-block-table">
|
|
<div className="ob-table-scroll ob-table-scroll--ask">
|
|
<table className="ob-table ob-table--hoga">
|
|
{COLGROUP}
|
|
<tbody>
|
|
{asks.map((unit, i) => (
|
|
<AskTableRow
|
|
key={`ask-${unit.price}-${i}`}
|
|
unit={unit}
|
|
prevClose={prevClose}
|
|
displayMode={summaryMode}
|
|
maxDisplay={maxDisplay}
|
|
currentPrice={currentPrice}
|
|
onClick={onRowClick}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
{tickerInfo && <StatsRail tickerInfo={tickerInfo} prevClose={prevClose} />}
|
|
</div>
|
|
|
|
{/* 매수 구간 + 좌측 체결 */}
|
|
<div className="ob-block ob-block--bid">
|
|
<TradesPanel trades={recentTrades} strength={tradeStrength} />
|
|
<div className="ob-block-table">
|
|
<div className="ob-table-scroll ob-table-scroll--bid">
|
|
<table className="ob-table ob-table--hoga">
|
|
{COLGROUP}
|
|
<tbody>
|
|
{bids.map((unit, i) => (
|
|
<BidTableRow
|
|
key={`bid-${unit.price}-${i}`}
|
|
unit={unit}
|
|
prevClose={prevClose}
|
|
displayMode={summaryMode}
|
|
maxDisplay={maxDisplay}
|
|
currentPrice={currentPrice}
|
|
onClick={onRowClick}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!isEmpty && (
|
|
<SummaryBar
|
|
asks={asks}
|
|
bids={bids}
|
|
totalAskSize={totalAskSize}
|
|
totalBidSize={totalBidSize}
|
|
clock={clock}
|
|
mode={summaryMode}
|
|
onModeChange={setSummaryMode}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default OrderbookPanel;
|