대시보드 중간 커밋
This commit is contained in:
574
features/dashboard/components/orderbook/OrderBook.tsx
Normal file
574
features/dashboard/components/orderbook/OrderBook.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
import { useEffect, useMemo, useRef } 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;
|
||||
|
||||
// 체결가 행 중앙 스크롤
|
||||
const centerRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
centerRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}, [latestPrice]);
|
||||
|
||||
// ─── 빈/로딩 상태 ───
|
||||
if (!symbol) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
종목을 선택해주세요.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isLoading && !orderBook) return <OrderBookSkeleton />;
|
||||
if (!orderBook) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
호가 정보를 가져오지 못했습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<Tabs defaultValue="normal" className="h-full min-h-0">
|
||||
{/* 탭 헤더 */}
|
||||
<div className="border-b px-2 pt-2">
|
||||
<TabsList variant="line" className="w-full justify-start">
|
||||
<TabsTrigger value="normal" className="px-3">
|
||||
일반호가
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cumulative" className="px-3">
|
||||
누적호가
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="order" className="px-3">
|
||||
호가주문
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* ── 일반호가 탭 ── */}
|
||||
<TabsContent value="normal" className="min-h-0 flex-1">
|
||||
<div className="grid h-full min-h-0 grid-rows-[1fr_190px] overflow-hidden border-t">
|
||||
<div className="grid min-h-0 grid-cols-[minmax(0,1fr)_150px] overflow-hidden">
|
||||
{/* 호가 테이블 */}
|
||||
<div className="min-h-0 border-r">
|
||||
<BookHeader />
|
||||
<ScrollArea className="h-[calc(100%-32px)]">
|
||||
{/* 매도호가 */}
|
||||
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
||||
|
||||
{/* 중앙 바: 현재 체결가 */}
|
||||
<div
|
||||
ref={centerRef}
|
||||
className="grid h-9 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30"
|
||||
>
|
||||
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground">
|
||||
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="text-xs font-bold tabular-nums">
|
||||
{latestPrice > 0
|
||||
? fmt(latestPrice)
|
||||
: bestAsk > 0
|
||||
? fmt(bestAsk)
|
||||
: "-"}
|
||||
</span>
|
||||
{latestPrice > 0 && basePrice > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-medium",
|
||||
latestPrice >= basePrice
|
||||
? "text-red-500"
|
||||
: "text-blue-500",
|
||||
)}
|
||||
>
|
||||
{fmtPct(pctChange(latestPrice, basePrice))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground">
|
||||
{totalBid > 0 ? fmt(totalBid) : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매수호가 */}
|
||||
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 우측 요약 패널 */}
|
||||
<SummaryPanel
|
||||
orderBook={orderBook}
|
||||
latestTick={latestTick}
|
||||
spread={spread}
|
||||
imbalance={imbalance}
|
||||
totalAsk={totalAsk}
|
||||
totalBid={totalBid}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 체결 목록 */}
|
||||
<TradeTape ticks={recentTicks} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── 누적호가 탭 ── */}
|
||||
<TabsContent value="cumulative" className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full border-t">
|
||||
<div className="p-3">
|
||||
<div className="mb-2 grid grid-cols-3 text-[11px] font-medium text-muted-foreground">
|
||||
<span>매도누적</span>
|
||||
<span className="text-center">호가</span>
|
||||
<span className="text-right">매수누적</span>
|
||||
</div>
|
||||
<CumulativeRows asks={askRows} bids={bidRows} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── 호가주문 탭 ── */}
|
||||
<TabsContent value="order" className="min-h-0 flex-1">
|
||||
<div className="flex h-full items-center justify-center border-t px-4 text-sm text-muted-foreground">
|
||||
호가주문 탭은 주문 입력 패널과 연동해 확장할 수 있습니다.
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 하위 컴포넌트 ──────────────────────────────────────
|
||||
|
||||
/** 호가 표 헤더 */
|
||||
function BookHeader() {
|
||||
return (
|
||||
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 text-[11px] font-medium text-muted-foreground">
|
||||
<div className="flex items-center justify-end px-2">매도잔량</div>
|
||||
<div className="flex items-center justify-center border-x">호가</div>
|
||||
<div className="flex items-center justify-start px-2">매수잔량</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 매도 또는 매수 호가 행 목록 */
|
||||
function BookSideRows({
|
||||
rows,
|
||||
side,
|
||||
maxSize,
|
||||
}: {
|
||||
rows: BookRow[];
|
||||
side: "ask" | "bid";
|
||||
maxSize: number;
|
||||
}) {
|
||||
const isAsk = side === "ask";
|
||||
|
||||
return (
|
||||
<div className={isAsk ? "bg-red-50/15" : "bg-blue-50/15"}>
|
||||
{rows.map((row, i) => {
|
||||
const ratio =
|
||||
maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${side}-${row.price}-${i}`}
|
||||
className={cn(
|
||||
"grid h-8 grid-cols-3 border-b border-border/40 text-xs",
|
||||
row.isHighlighted &&
|
||||
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30",
|
||||
)}
|
||||
>
|
||||
{/* 매도잔량 (좌측) */}
|
||||
<div className="relative flex items-center justify-end overflow-hidden px-2">
|
||||
{isAsk && (
|
||||
<>
|
||||
<DepthBar ratio={ratio} side="ask" />
|
||||
<AnimatedQuantity
|
||||
value={row.size}
|
||||
format={fmt}
|
||||
useColor
|
||||
className="relative z-10"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 호가 (중앙) */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
|
||||
row.isHighlighted &&
|
||||
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-900/25",
|
||||
)}
|
||||
>
|
||||
<span className={isAsk ? "text-red-600" : "text-blue-600"}>
|
||||
{row.price > 0 ? fmt(row.price) : "-"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
row.changePercent !== null
|
||||
? row.changePercent >= 0
|
||||
? "text-red-500"
|
||||
: "text-blue-500"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{row.changePercent === null ? "-" : fmtPct(row.changePercent)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 매수잔량 (우측) */}
|
||||
<div className="relative flex items-center justify-start overflow-hidden px-1">
|
||||
{!isAsk && (
|
||||
<>
|
||||
<DepthBar ratio={ratio} side="bid" />
|
||||
<AnimatedQuantity
|
||||
value={row.size}
|
||||
format={fmt}
|
||||
useColor
|
||||
className="relative z-10"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 우측 요약 패널 */
|
||||
function SummaryPanel({
|
||||
orderBook,
|
||||
latestTick,
|
||||
spread,
|
||||
imbalance,
|
||||
totalAsk,
|
||||
totalBid,
|
||||
}: {
|
||||
orderBook: DashboardStockOrderBookResponse;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
spread: number;
|
||||
imbalance: number;
|
||||
totalAsk: number;
|
||||
totalBid: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-l bg-muted/15 p-2 text-[11px]">
|
||||
<Row
|
||||
label="실시간"
|
||||
value={orderBook ? "연결됨" : "끊김"}
|
||||
tone={orderBook ? "bid" : undefined}
|
||||
/>
|
||||
<Row
|
||||
label="거래량"
|
||||
value={fmt(latestTick?.tradeVolume ?? orderBook.anticipatedVolume ?? 0)}
|
||||
/>
|
||||
<Row
|
||||
label="누적거래량"
|
||||
value={fmt(
|
||||
latestTick?.accumulatedVolume ?? orderBook.accumulatedVolume ?? 0,
|
||||
)}
|
||||
/>
|
||||
<Row
|
||||
label="체결강도"
|
||||
value={
|
||||
latestTick
|
||||
? `${latestTick.tradeStrength.toFixed(2)}%`
|
||||
: orderBook.anticipatedChangeRate !== undefined
|
||||
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
|
||||
: "-"
|
||||
}
|
||||
/>
|
||||
<Row label="예상체결가" value={fmt(orderBook.anticipatedPrice ?? 0)} />
|
||||
<Row
|
||||
label="매도1호가"
|
||||
value={latestTick ? fmt(latestTick.askPrice1) : "-"}
|
||||
tone="ask"
|
||||
/>
|
||||
<Row
|
||||
label="매수1호가"
|
||||
value={latestTick ? fmt(latestTick.bidPrice1) : "-"}
|
||||
tone="bid"
|
||||
/>
|
||||
<Row
|
||||
label="매수체결"
|
||||
value={latestTick ? fmt(latestTick.buyExecutionCount) : "-"}
|
||||
/>
|
||||
<Row
|
||||
label="매도체결"
|
||||
value={latestTick ? fmt(latestTick.sellExecutionCount) : "-"}
|
||||
/>
|
||||
<Row
|
||||
label="순매수체결"
|
||||
value={latestTick ? fmt(latestTick.netBuyExecutionCount) : "-"}
|
||||
/>
|
||||
<Row label="총 매도잔량" value={fmt(totalAsk)} tone="ask" />
|
||||
<Row label="총 매수잔량" value={fmt(totalBid)} tone="bid" />
|
||||
<Row label="스프레드" value={fmt(spread)} />
|
||||
<Row
|
||||
label="수급 불균형"
|
||||
value={`${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`}
|
||||
tone={imbalance >= 0 ? "bid" : "ask"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 요약 패널 단일 행 */
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "ask" | "bid";
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-1.5 flex items-center justify-between rounded border bg-background px-2 py-1">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium tabular-nums",
|
||||
tone === "ask" && "text-red-600",
|
||||
tone === "bid" && "text-blue-600",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 잔량 깊이 바 */
|
||||
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
||||
if (ratio <= 0) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-1 z-0 rounded-sm",
|
||||
side === "ask" ? "right-1 bg-red-200/50" : "left-1 bg-blue-200/50",
|
||||
)}
|
||||
style={{ width: `${ratio}%` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 체결 목록 (Trade Tape) */
|
||||
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
||||
return (
|
||||
<div className="border-t bg-background">
|
||||
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground">
|
||||
<div className="flex items-center">체결시각</div>
|
||||
<div className="flex items-center justify-end">체결가</div>
|
||||
<div className="flex items-center justify-end">체결량</div>
|
||||
<div className="flex items-center justify-end">체결강도</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[162px]">
|
||||
<div>
|
||||
{ticks.length === 0 && (
|
||||
<div className="flex h-[160px] items-center justify-center text-xs text-muted-foreground">
|
||||
체결 데이터가 아직 없습니다.
|
||||
</div>
|
||||
)}
|
||||
{ticks.map((t, i) => (
|
||||
<div
|
||||
key={`${t.tickTime}-${t.price}-${i}`}
|
||||
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs"
|
||||
>
|
||||
<div className="flex items-center tabular-nums">
|
||||
{fmtTime(t.tickTime)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums text-red-600">
|
||||
{fmt(t.price)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums text-blue-600">
|
||||
{fmt(t.tradeVolume)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums">
|
||||
{t.tradeStrength.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 누적호가 행 */
|
||||
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 (
|
||||
<div className="space-y-1">
|
||||
{rows.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid h-7 grid-cols-3 items-center rounded border bg-background px-2 text-xs"
|
||||
>
|
||||
<span className="tabular-nums text-red-600">{fmt(r.askAcc)}</span>
|
||||
<span className="text-center font-medium tabular-nums">
|
||||
{fmt(r.price)}
|
||||
</span>
|
||||
<span className="text-right tabular-nums text-blue-600">
|
||||
{fmt(r.bidAcc)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 로딩 스켈레톤 */
|
||||
function OrderBookSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3">
|
||||
<div className="mb-3 grid grid-cols-3 gap-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 16 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-7 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user