764 lines
24 KiB
TypeScript
764 lines
24 KiB
TypeScript
|
|
import { useEffect, 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 {
|
||
|
|
DashboardOrderBookLevel,
|
||
|
|
DashboardRealtimeTradeTick,
|
||
|
|
DashboardStockOrderBookResponse,
|
||
|
|
} from "@/features/dashboard/types/dashboard.types";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
const FIXED_DEPTH_COUNT = 10;
|
||
|
|
|
||
|
|
interface OrderBookProps {
|
||
|
|
symbol?: string;
|
||
|
|
referencePrice?: number;
|
||
|
|
currentPrice?: number;
|
||
|
|
latestTick: DashboardRealtimeTradeTick | null;
|
||
|
|
recentTicks: DashboardRealtimeTradeTick[];
|
||
|
|
orderBook: DashboardStockOrderBookResponse | null;
|
||
|
|
isLoading?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @description 호가창(일반호가/누적호가/호가주문) UI
|
||
|
|
* @see features/dashboard/hooks/useOrderBook.ts 초기호가 + 실시간호가 결합
|
||
|
|
*/
|
||
|
|
export function OrderBook({
|
||
|
|
symbol,
|
||
|
|
referencePrice,
|
||
|
|
currentPrice,
|
||
|
|
latestTick,
|
||
|
|
recentTicks,
|
||
|
|
orderBook,
|
||
|
|
isLoading,
|
||
|
|
}: OrderBookProps) {
|
||
|
|
const prevAskSizeByPriceRef = useRef<Map<number, number>>(new Map());
|
||
|
|
const prevBidSizeByPriceRef = useRef<Map<number, number>>(new Map());
|
||
|
|
|
||
|
|
// TODO: 상위 컴포넌트에서 실제 연결 상태를 props로 전달하도록 개선
|
||
|
|
const isTradeConnected = !!orderBook;
|
||
|
|
|
||
|
|
// ========== DERIVED STATE ==========
|
||
|
|
const rawAsks = orderBook ? [...orderBook.levels].reverse() : [];
|
||
|
|
const rawBids = orderBook ? orderBook.levels : [];
|
||
|
|
|
||
|
|
// ========== FIXED DEPTH ROWS ==========
|
||
|
|
const asks = toFixedDepthRows(rawAsks, FIXED_DEPTH_COUNT, "start");
|
||
|
|
const bids = toFixedDepthRows(rawBids, FIXED_DEPTH_COUNT, "end");
|
||
|
|
|
||
|
|
const bestAsk = findFirstPositive(asks, "askPrice");
|
||
|
|
const bestBid = findFirstPositive(bids, "bidPrice");
|
||
|
|
const spread =
|
||
|
|
bestAsk > 0 && bestBid > 0 ? Math.max(bestAsk - bestBid, 0) : 0;
|
||
|
|
const imbalance = calcImbalance(
|
||
|
|
orderBook ? orderBook.totalBidSize : 0,
|
||
|
|
orderBook ? orderBook.totalAskSize : 0,
|
||
|
|
);
|
||
|
|
|
||
|
|
// 최근 체결가와 정확히 일치하는 호가만 강조
|
||
|
|
const highlightedPrice = resolveHighlightedPrice(latestTick?.price, asks, bids);
|
||
|
|
|
||
|
|
const percentBasePrice =
|
||
|
|
referencePrice && referencePrice > 0
|
||
|
|
? referencePrice
|
||
|
|
: currentPrice && currentPrice > 0
|
||
|
|
? currentPrice
|
||
|
|
: 0;
|
||
|
|
|
||
|
|
// eslint-disable-next-line react-hooks/refs
|
||
|
|
const prevAskSizeByPrice = prevAskSizeByPriceRef.current;
|
||
|
|
// eslint-disable-next-line react-hooks/refs
|
||
|
|
const prevBidSizeByPrice = prevBidSizeByPriceRef.current;
|
||
|
|
|
||
|
|
// ========== PREV SNAPSHOT ==========
|
||
|
|
useEffect(() => {
|
||
|
|
if (!orderBook) {
|
||
|
|
prevAskSizeByPriceRef.current = new Map();
|
||
|
|
prevBidSizeByPriceRef.current = new Map();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const snapshotAsks = toFixedDepthRows(
|
||
|
|
[...orderBook.levels].reverse(),
|
||
|
|
FIXED_DEPTH_COUNT,
|
||
|
|
"start",
|
||
|
|
);
|
||
|
|
const snapshotBids = toFixedDepthRows(
|
||
|
|
orderBook.levels,
|
||
|
|
FIXED_DEPTH_COUNT,
|
||
|
|
"end",
|
||
|
|
);
|
||
|
|
|
||
|
|
const nextAskMap = new Map<number, number>();
|
||
|
|
const nextBidMap = new Map<number, number>();
|
||
|
|
|
||
|
|
snapshotAsks.forEach((row) => {
|
||
|
|
if (row.askPrice > 0) {
|
||
|
|
nextAskMap.set(row.askPrice, row.askSize);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
snapshotBids.forEach((row) => {
|
||
|
|
if (row.bidPrice > 0) {
|
||
|
|
nextBidMap.set(row.bidPrice, row.bidSize);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
prevAskSizeByPriceRef.current = nextAskMap;
|
||
|
|
prevBidSizeByPriceRef.current = nextBidMap;
|
||
|
|
}, [orderBook]);
|
||
|
|
|
||
|
|
// ========== EMPTY STATE ==========
|
||
|
|
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">
|
||
|
|
{/* ========== TAB HEADER ========== */}
|
||
|
|
<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>
|
||
|
|
|
||
|
|
{/* ========== NORMAL TAB ========== */}
|
||
|
|
<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">
|
||
|
|
{/* ========== ORDER BOOK BODY ========== */}
|
||
|
|
<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={asks}
|
||
|
|
side="ask"
|
||
|
|
maxSize={orderBook.totalAskSize}
|
||
|
|
percentBasePrice={percentBasePrice}
|
||
|
|
highlightedPrice={highlightedPrice}
|
||
|
|
previousSizeByPrice={prevAskSizeByPrice}
|
||
|
|
/>
|
||
|
|
<BookCenterRow
|
||
|
|
bestAsk={bestAsk}
|
||
|
|
bestBid={bestBid}
|
||
|
|
spread={spread}
|
||
|
|
imbalance={imbalance}
|
||
|
|
latestTick={latestTick}
|
||
|
|
/>
|
||
|
|
<BookSideRows
|
||
|
|
rows={bids}
|
||
|
|
side="bid"
|
||
|
|
maxSize={orderBook.totalBidSize}
|
||
|
|
percentBasePrice={percentBasePrice}
|
||
|
|
highlightedPrice={highlightedPrice}
|
||
|
|
previousSizeByPrice={prevBidSizeByPrice}
|
||
|
|
/>
|
||
|
|
</ScrollArea>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<BookRealtimeSummary
|
||
|
|
orderBook={orderBook}
|
||
|
|
latestTick={latestTick}
|
||
|
|
spread={spread}
|
||
|
|
imbalance={imbalance}
|
||
|
|
totalAskSize={orderBook.totalAskSize}
|
||
|
|
totalBidSize={orderBook.totalBidSize}
|
||
|
|
isTradeConnected={isTradeConnected}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* ========== TRADE TAPE ========== */}
|
||
|
|
<TradeTape ticks={recentTicks} />
|
||
|
|
</div>
|
||
|
|
</TabsContent>
|
||
|
|
|
||
|
|
{/* ========== CUMULATIVE TAB ========== */}
|
||
|
|
<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={asks} bids={bids} />
|
||
|
|
</div>
|
||
|
|
</ScrollArea>
|
||
|
|
</TabsContent>
|
||
|
|
|
||
|
|
{/* ========== ORDER TAB ========== */}
|
||
|
|
<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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @description 호가 테이블 머리글
|
||
|
|
* @see OrderBook 일반호가 탭 본문
|
||
|
|
*/
|
||
|
|
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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @description 매도/매수 호가 행 목록
|
||
|
|
* @see OrderBook 일반호가 탭 본문
|
||
|
|
*/
|
||
|
|
function BookSideRows({
|
||
|
|
rows,
|
||
|
|
side,
|
||
|
|
maxSize,
|
||
|
|
percentBasePrice,
|
||
|
|
highlightedPrice,
|
||
|
|
previousSizeByPrice,
|
||
|
|
}: {
|
||
|
|
rows: DashboardOrderBookLevel[];
|
||
|
|
side: "ask" | "bid";
|
||
|
|
maxSize: number;
|
||
|
|
percentBasePrice: number;
|
||
|
|
highlightedPrice: number;
|
||
|
|
previousSizeByPrice: Map<number, number>;
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<div className={cn(side === "ask" ? "bg-red-50/20" : "bg-blue-50/20")}>
|
||
|
|
{rows.map((row, index) => {
|
||
|
|
const price = side === "ask" ? row.askPrice : row.bidPrice;
|
||
|
|
const size = side === "ask" ? row.askSize : row.bidSize;
|
||
|
|
const ratio = maxSize > 0 ? Math.min((size / maxSize) * 100, 100) : 0;
|
||
|
|
const isHighlightedRow =
|
||
|
|
highlightedPrice > 0 && price > 0 && price === highlightedPrice;
|
||
|
|
|
||
|
|
const prevSize = price > 0 ? previousSizeByPrice.get(price) : undefined;
|
||
|
|
const delta = prevSize === undefined ? null : size - prevSize;
|
||
|
|
const deltaRate =
|
||
|
|
delta === null
|
||
|
|
? null
|
||
|
|
: prevSize !== undefined && prevSize > 0
|
||
|
|
? (delta / prevSize) * 100
|
||
|
|
: delta > 0
|
||
|
|
? 100
|
||
|
|
: 0;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={`${side}-${index}-${price}-${size}`}
|
||
|
|
className={cn(
|
||
|
|
"grid h-8 grid-cols-3 border-b border-border/40 text-xs",
|
||
|
|
isHighlightedRow && "bg-amber-100/40 dark:bg-amber-900/20",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div className="relative flex items-center justify-end px-2">
|
||
|
|
{side === "ask" && (
|
||
|
|
<>
|
||
|
|
<DepthBar ratio={ratio} side={side} />
|
||
|
|
<div className="relative z-10 flex w-full items-center justify-end gap-1">
|
||
|
|
<span className="tabular-nums">{formatNumber(size)}</span>
|
||
|
|
<SizeDeltaIndicator delta={delta} deltaRate={deltaRate} />
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"flex items-center justify-center border-x font-medium tabular-nums",
|
||
|
|
isHighlightedRow &&
|
||
|
|
"relative border-x-amber-400 bg-amber-50/70 dark:bg-amber-900/25",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div className="flex w-full items-center justify-center gap-1">
|
||
|
|
<span
|
||
|
|
className={cn(
|
||
|
|
side === "ask" ? "text-red-600" : "text-blue-600",
|
||
|
|
price <= 0 && "text-muted-foreground",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{price > 0 ? formatNumber(price) : "-"}
|
||
|
|
</span>
|
||
|
|
<span
|
||
|
|
className={cn(
|
||
|
|
"text-[10px]",
|
||
|
|
price > 0 && percentBasePrice > 0
|
||
|
|
? calcChangePercent(price, percentBasePrice) >= 0
|
||
|
|
? "text-red-500"
|
||
|
|
: "text-blue-500"
|
||
|
|
: "text-muted-foreground",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{price > 0 && percentBasePrice > 0
|
||
|
|
? formatSignedPercent(calcChangePercent(price, percentBasePrice))
|
||
|
|
: "-"}
|
||
|
|
</span>
|
||
|
|
{isHighlightedRow && (
|
||
|
|
<span className="text-[10px] font-semibold text-amber-600 dark:text-amber-400">
|
||
|
|
현재가
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="relative flex items-center justify-start px-2">
|
||
|
|
{side === "bid" && (
|
||
|
|
<>
|
||
|
|
<DepthBar ratio={ratio} side={side} />
|
||
|
|
<div className="relative z-10 flex w-full items-center justify-start gap-1">
|
||
|
|
<span className="tabular-nums">{formatNumber(size)}</span>
|
||
|
|
<SizeDeltaIndicator delta={delta} deltaRate={deltaRate} />
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @description 잔량 증감 표시(절대값 + 증감률)
|
||
|
|
* @see BookSideRows 수량 변경 시 +/-% 표시
|
||
|
|
*/
|
||
|
|
function SizeDeltaIndicator({
|
||
|
|
delta,
|
||
|
|
deltaRate,
|
||
|
|
}: {
|
||
|
|
delta: number | null;
|
||
|
|
deltaRate: number | null;
|
||
|
|
}) {
|
||
|
|
if (delta === null || delta === 0 || deltaRate === null) {
|
||
|
|
return <span className="text-[10px] text-muted-foreground">-</span>;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<span
|
||
|
|
className={cn(
|
||
|
|
"text-[10px] tabular-nums",
|
||
|
|
delta > 0 ? "text-red-500" : "text-blue-500",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{formatSignedNumber(delta)} ({formatSignedPercent(deltaRate)})
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @description 호가 중앙 요약(스프레드/체결강도/수급)
|
||
|
|
* @see OrderBook 일반호가 탭 본문
|
||
|
|
*/
|
||
|
|
function BookCenterRow({
|
||
|
|
bestAsk,
|
||
|
|
bestBid,
|
||
|
|
spread,
|
||
|
|
imbalance,
|
||
|
|
latestTick,
|
||
|
|
}: {
|
||
|
|
bestAsk: number;
|
||
|
|
bestBid: number;
|
||
|
|
spread: number;
|
||
|
|
imbalance: number;
|
||
|
|
latestTick: DashboardRealtimeTradeTick | null;
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<div className="border-y bg-background">
|
||
|
|
<div className="grid h-8 grid-cols-3 text-[11px]">
|
||
|
|
<div className="flex items-center justify-end px-2 text-red-600 tabular-nums">
|
||
|
|
{bestAsk > 0 ? `매도1 ${formatNumber(bestAsk)}` : "-"}
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center justify-center border-x text-muted-foreground">
|
||
|
|
스프레드 {formatNumber(spread)}
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center justify-start px-2 text-blue-600 tabular-nums">
|
||
|
|
{bestBid > 0 ? `매수1 ${formatNumber(bestBid)}` : "-"}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="grid h-7 grid-cols-3 border-t text-[10px]">
|
||
|
|
<div className="flex items-center justify-end px-2 text-muted-foreground">
|
||
|
|
체결강도 {latestTick ? `${latestTick.tradeStrength.toFixed(2)}%` : "-"}
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center justify-center border-x text-muted-foreground">
|
||
|
|
수급 불균형 {imbalance >= 0 ? "+" : ""}
|
||
|
|
{imbalance.toFixed(2)}%
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center justify-start px-2 text-muted-foreground">
|
||
|
|
체결량 {latestTick ? formatNumber(latestTick.tradeVolume) : "-"}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function BookRealtimeSummary({
|
||
|
|
orderBook,
|
||
|
|
latestTick,
|
||
|
|
spread,
|
||
|
|
imbalance,
|
||
|
|
totalAskSize,
|
||
|
|
totalBidSize,
|
||
|
|
isTradeConnected,
|
||
|
|
}: {
|
||
|
|
orderBook: {
|
||
|
|
accumulatedVolume?: number;
|
||
|
|
anticipatedVolume?: number;
|
||
|
|
anticipatedPrice?: number;
|
||
|
|
anticipatedChangeRate?: number;
|
||
|
|
};
|
||
|
|
latestTick: DashboardRealtimeTradeTick | null;
|
||
|
|
spread: number;
|
||
|
|
imbalance: number;
|
||
|
|
totalAskSize: number;
|
||
|
|
totalBidSize: number;
|
||
|
|
isTradeConnected: boolean;
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<div className="border-l bg-muted/15 p-2 text-[11px]">
|
||
|
|
{/* ========== WS STATUS ========== */}
|
||
|
|
<div className="mb-2 flex items-center justify-between rounded border bg-background px-2 py-1">
|
||
|
|
<span className="text-muted-foreground">실시간</span>
|
||
|
|
<span
|
||
|
|
className={cn(
|
||
|
|
"font-medium",
|
||
|
|
isTradeConnected ? "text-green-600" : "text-muted-foreground",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{isTradeConnected ? "연결됨" : "연결 대기"}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<SummaryItem
|
||
|
|
label="거래량"
|
||
|
|
value={formatNumber(latestTick?.tradeVolume ?? orderBook.anticipatedVolume ?? 0)}
|
||
|
|
/>
|
||
|
|
<SummaryItem
|
||
|
|
label="누적거래량"
|
||
|
|
value={formatNumber(
|
||
|
|
latestTick?.accumulatedVolume ?? orderBook.accumulatedVolume ?? 0,
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
<SummaryItem
|
||
|
|
label="체결강도"
|
||
|
|
value={
|
||
|
|
latestTick
|
||
|
|
? `${latestTick.tradeStrength.toFixed(2)}%`
|
||
|
|
: orderBook.anticipatedChangeRate !== undefined
|
||
|
|
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
|
||
|
|
: "-"
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
<SummaryItem
|
||
|
|
label="예상체결가"
|
||
|
|
value={formatNumber(orderBook.anticipatedPrice ?? 0)}
|
||
|
|
/>
|
||
|
|
<SummaryItem
|
||
|
|
label="매도1호가"
|
||
|
|
value={latestTick ? formatNumber(latestTick.askPrice1) : "-"}
|
||
|
|
tone="ask"
|
||
|
|
/>
|
||
|
|
<SummaryItem
|
||
|
|
label="매수1호가"
|
||
|
|
value={latestTick ? formatNumber(latestTick.bidPrice1) : "-"}
|
||
|
|
tone="bid"
|
||
|
|
/>
|
||
|
|
<SummaryItem
|
||
|
|
label="매수체결"
|
||
|
|
value={latestTick ? formatNumber(latestTick.buyExecutionCount) : "-"}
|
||
|
|
/>
|
||
|
|
<SummaryItem
|
||
|
|
label="매도체결"
|
||
|
|
value={latestTick ? formatNumber(latestTick.sellExecutionCount) : "-"}
|
||
|
|
/>
|
||
|
|
<SummaryItem
|
||
|
|
label="순매수체결"
|
||
|
|
value={latestTick ? formatNumber(latestTick.netBuyExecutionCount) : "-"}
|
||
|
|
/>
|
||
|
|
<SummaryItem label="총 매도잔량" value={formatNumber(totalAskSize)} tone="ask" />
|
||
|
|
<SummaryItem label="총 매수잔량" value={formatNumber(totalBidSize)} tone="bid" />
|
||
|
|
<SummaryItem label="스프레드" value={formatNumber(spread)} />
|
||
|
|
<SummaryItem
|
||
|
|
label="수급 불균형"
|
||
|
|
value={`${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`}
|
||
|
|
tone={imbalance >= 0 ? "bid" : "ask"}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function SummaryItem({
|
||
|
|
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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @description 체결 테이프
|
||
|
|
* @see features/dashboard/hooks/useKisTradeWebSocket.ts recentTradeTicks
|
||
|
|
*/
|
||
|
|
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
||
|
|
return (
|
||
|
|
<div className="border-t bg-background">
|
||
|
|
{/* ========== TRADE TAPE HEADER ========== */}
|
||
|
|
<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>
|
||
|
|
|
||
|
|
{/* ========== TRADE TAPE ROWS ========== */}
|
||
|
|
<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((tick, index) => (
|
||
|
|
<div
|
||
|
|
key={`${tick.tickTime}-${tick.price}-${tick.tradeVolume}-${index}`}
|
||
|
|
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs"
|
||
|
|
>
|
||
|
|
<div className="flex items-center tabular-nums">
|
||
|
|
{formatTickTime(tick.tickTime)}
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center justify-end tabular-nums text-red-600">
|
||
|
|
{formatNumber(tick.price)}
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center justify-end tabular-nums text-blue-600">
|
||
|
|
{formatNumber(tick.tradeVolume)}
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center justify-end tabular-nums">
|
||
|
|
{tick.tradeStrength.toFixed(2)}%
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</ScrollArea>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @description 누적호가 행 계산
|
||
|
|
* @see OrderBook 누적호가 탭
|
||
|
|
*/
|
||
|
|
function CumulativeRows({
|
||
|
|
asks,
|
||
|
|
bids,
|
||
|
|
}: {
|
||
|
|
asks: DashboardOrderBookLevel[];
|
||
|
|
bids: DashboardOrderBookLevel[];
|
||
|
|
}) {
|
||
|
|
const maxLen = Math.max(asks.length, bids.length);
|
||
|
|
const cumulativeRows: Array<{
|
||
|
|
key: string;
|
||
|
|
askAcc: number;
|
||
|
|
bidAcc: number;
|
||
|
|
price: number;
|
||
|
|
}> = [];
|
||
|
|
|
||
|
|
let askAcc = 0;
|
||
|
|
let bidAcc = 0;
|
||
|
|
|
||
|
|
for (let index = 0; index < maxLen; index += 1) {
|
||
|
|
const askRow = asks[index];
|
||
|
|
const bidRow = bids[index];
|
||
|
|
|
||
|
|
askAcc += askRow?.askSize ?? 0;
|
||
|
|
bidAcc += bidRow?.bidSize ?? 0;
|
||
|
|
|
||
|
|
cumulativeRows.push({
|
||
|
|
key: `acc-${index}`,
|
||
|
|
askAcc,
|
||
|
|
bidAcc,
|
||
|
|
price: askRow?.askPrice || bidRow?.bidPrice || 0,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-1">
|
||
|
|
{cumulativeRows.map((row) => (
|
||
|
|
<div
|
||
|
|
key={row.key}
|
||
|
|
className="grid h-7 grid-cols-3 items-center rounded border bg-background px-2 text-xs"
|
||
|
|
>
|
||
|
|
<span className="tabular-nums text-red-600">{formatNumber(row.askAcc)}</span>
|
||
|
|
<span className="text-center font-medium tabular-nums">
|
||
|
|
{formatNumber(row.price)}
|
||
|
|
</span>
|
||
|
|
<span className="text-right tabular-nums text-blue-600">
|
||
|
|
{formatNumber(row.bidAcc)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
||
|
|
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}%` }}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function findFirstPositive(
|
||
|
|
rows: DashboardOrderBookLevel[],
|
||
|
|
key: "askPrice" | "bidPrice",
|
||
|
|
) {
|
||
|
|
const row = rows.find((item) => item[key] > 0);
|
||
|
|
return row?.[key] ?? 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
function calcImbalance(totalBid: number, totalAsk: number) {
|
||
|
|
const sum = totalBid + totalAsk;
|
||
|
|
if (sum === 0) return 0;
|
||
|
|
return ((totalBid - totalAsk) / sum) * 100;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @description 호가 행 개수를 고정(기본 10)하여 UI 흔들림을 방지합니다.
|
||
|
|
* @see OrderBook 상단/하단 호가 행 고정 렌더링
|
||
|
|
*/
|
||
|
|
function toFixedDepthRows(
|
||
|
|
rows: DashboardOrderBookLevel[],
|
||
|
|
targetCount: number,
|
||
|
|
padDirection: "start" | "end",
|
||
|
|
) {
|
||
|
|
if (rows.length === targetCount) return rows;
|
||
|
|
|
||
|
|
if (rows.length > targetCount) {
|
||
|
|
return rows.slice(0, targetCount);
|
||
|
|
}
|
||
|
|
|
||
|
|
const emptyRows = Array.from({ length: targetCount - rows.length }, () => ({
|
||
|
|
askPrice: 0,
|
||
|
|
bidPrice: 0,
|
||
|
|
askSize: 0,
|
||
|
|
bidSize: 0,
|
||
|
|
}));
|
||
|
|
|
||
|
|
return padDirection === "start"
|
||
|
|
? [...emptyRows, ...rows]
|
||
|
|
: [...rows, ...emptyRows];
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveHighlightedPrice(
|
||
|
|
latestTickPrice: number | undefined,
|
||
|
|
asks: DashboardOrderBookLevel[],
|
||
|
|
bids: DashboardOrderBookLevel[],
|
||
|
|
) {
|
||
|
|
const preferred = latestTickPrice && latestTickPrice > 0 ? latestTickPrice : 0;
|
||
|
|
if (preferred <= 0) return 0;
|
||
|
|
|
||
|
|
const candidates = [
|
||
|
|
...asks.map((row) => row.askPrice),
|
||
|
|
...bids.map((row) => row.bidPrice),
|
||
|
|
].filter((price) => price > 0);
|
||
|
|
|
||
|
|
if (candidates.length === 0) return 0;
|
||
|
|
return candidates.includes(preferred) ? preferred : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatTickTime(hhmmss: string) {
|
||
|
|
if (!hhmmss || hhmmss.length !== 6) return "--:--:--";
|
||
|
|
return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatNumber(value: number) {
|
||
|
|
return Number.isFinite(value) ? value.toLocaleString("ko-KR") : "0";
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatSignedNumber(value: number) {
|
||
|
|
const sign = value > 0 ? "+" : "";
|
||
|
|
return `${sign}${value.toLocaleString("ko-KR")}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function calcChangePercent(price: number, basePrice: number) {
|
||
|
|
if (basePrice <= 0) return 0;
|
||
|
|
return ((price - basePrice) / basePrice) * 100;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatSignedPercent(value: number) {
|
||
|
|
const sign = value > 0 ? "+" : "";
|
||
|
|
return `${sign}${value.toFixed(2)}%`;
|
||
|
|
}
|