차트 수정
This commit is contained in:
@@ -28,6 +28,40 @@ interface BookRow {
|
||||
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),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ─── 유틸리티 함수 ──────────────────────────────────────
|
||||
|
||||
/** 천단위 구분 포맷 */
|
||||
@@ -111,7 +145,17 @@ export function OrderBook({
|
||||
orderBook,
|
||||
isLoading,
|
||||
}: OrderBookProps) {
|
||||
const levels = useMemo(() => orderBook?.levels ?? [], [orderBook]);
|
||||
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 =
|
||||
@@ -164,8 +208,14 @@ export function OrderBook({
|
||||
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 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
|
||||
@@ -181,8 +231,10 @@ export function OrderBook({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isLoading && !orderBook) return <OrderBookSkeleton />;
|
||||
if (!orderBook) {
|
||||
if (isLoading && !orderBook && fallbackLevelsFromTick.length === 0) {
|
||||
return <OrderBookSkeleton />;
|
||||
}
|
||||
if (!orderBook && fallbackLevelsFromTick.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
호가 정보를 가져오지 못했습니다.
|
||||
@@ -210,68 +262,72 @@ export function OrderBook({
|
||||
|
||||
{/* ── 일반호가 탭 ── */}
|
||||
<TabsContent value="normal" className="min-h-0 flex-1">
|
||||
<div className="block h-full min-h-0 border-t dark:border-brand-800/45 xl:grid xl:grid-rows-[1fr_190px] xl:overflow-hidden">
|
||||
<div className="block min-h-0 xl:grid xl:grid-cols-[minmax(0,1fr)_168px] xl:overflow-hidden">
|
||||
{/* 호가 테이블 */}
|
||||
<div className="min-h-0 xl:border-r dark:border-brand-800/45">
|
||||
<BookHeader />
|
||||
<ScrollArea className="h-[320px] sm:h-[360px] xl:h-[calc(100%-32px)] [&>[data-slot=scroll-area-scrollbar]]:hidden xl:[&>[data-slot=scroll-area-scrollbar]]:flex">
|
||||
{/* 매도호가 */}
|
||||
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
||||
<div className="flex h-full min-h-0 flex-col border-t dark:border-brand-800/45 xl:grid xl:grid-cols-[minmax(0,1fr)_320px_168px] xl:overflow-hidden">
|
||||
{/* 호가 테이블 */}
|
||||
<div className="flex min-h-0 flex-col xl:border-r dark:border-brand-800/45">
|
||||
{isTickFallbackActive && (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-2 py-1 text-[11px] text-amber-700 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200">
|
||||
시간외 전용 호가(`H0STOAA0`) 미수신 상태입니다. 체결(`H0UNCNT0`)
|
||||
1호가 기준으로 표시 중입니다.
|
||||
</div>
|
||||
)}
|
||||
<BookHeader />
|
||||
<ScrollArea className="min-h-0 flex-1 [&>[data-slot=scroll-area-scrollbar]]:hidden xl:[&>[data-slot=scroll-area-scrollbar]]:flex">
|
||||
{/* 매도호가 */}
|
||||
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
||||
|
||||
{/* 중앙 바: 현재 체결가 */}
|
||||
<div className="grid h-8 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30 xl:h-9">
|
||||
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
|
||||
{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-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{fmtPct(pctChange(latestPrice, basePrice))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
|
||||
{totalBid > 0 ? fmt(totalBid) : ""}
|
||||
</div>
|
||||
{/* 중앙 바: 현재 체결가 */}
|
||||
<div className="grid h-8 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30 xl:h-9">
|
||||
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
|
||||
{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-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{fmtPct(pctChange(latestPrice, basePrice))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
|
||||
{totalBid > 0 ? fmt(totalBid) : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매수호가 */}
|
||||
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 우측 요약 패널 */}
|
||||
<div className="hidden xl:block">
|
||||
<SummaryPanel
|
||||
orderBook={orderBook}
|
||||
latestTick={latestTick}
|
||||
spread={spread}
|
||||
imbalance={imbalance}
|
||||
totalAsk={totalAsk}
|
||||
totalBid={totalBid}
|
||||
/>
|
||||
</div>
|
||||
{/* 매수호가 */}
|
||||
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 체결 목록 */}
|
||||
<div className="hidden xl:block">
|
||||
{/* 체결 목록: 데스크톱에서는 호가 오른쪽, 모바일에서는 아래 */}
|
||||
<div className="min-h-[220px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0 xl:border-r">
|
||||
<TradeTape ticks={recentTicks} />
|
||||
</div>
|
||||
|
||||
{/* 우측 요약 패널 */}
|
||||
<div className="hidden xl:block min-h-0">
|
||||
<SummaryPanel
|
||||
orderBook={orderBook}
|
||||
latestTick={latestTick}
|
||||
spread={spread}
|
||||
imbalance={imbalance}
|
||||
totalAsk={totalAsk}
|
||||
totalBid={totalBid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -430,7 +486,7 @@ function SummaryPanel({
|
||||
totalAsk,
|
||||
totalBid,
|
||||
}: {
|
||||
orderBook: DashboardStockOrderBookResponse;
|
||||
orderBook: DashboardStockOrderBookResponse | null;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
spread: number;
|
||||
imbalance: number;
|
||||
@@ -441,17 +497,17 @@ function SummaryPanel({
|
||||
<div className="min-w-0 border-l bg-muted/10 p-2 text-[11px] dark:border-brand-800/45 dark:bg-brand-900/30">
|
||||
<Row
|
||||
label="실시간"
|
||||
value={orderBook ? "연결됨" : "끊김"}
|
||||
tone={orderBook ? "bid" : undefined}
|
||||
value={orderBook || latestTick ? "연결됨" : "끊김"}
|
||||
tone={orderBook || latestTick ? "bid" : undefined}
|
||||
/>
|
||||
<Row
|
||||
label="거래량"
|
||||
value={fmt(latestTick?.tradeVolume ?? orderBook.anticipatedVolume ?? 0)}
|
||||
value={fmt(latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0)}
|
||||
/>
|
||||
<Row
|
||||
label="누적거래량"
|
||||
value={fmt(
|
||||
latestTick?.accumulatedVolume ?? orderBook.accumulatedVolume ?? 0,
|
||||
latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0,
|
||||
)}
|
||||
/>
|
||||
<Row
|
||||
@@ -459,12 +515,12 @@ function SummaryPanel({
|
||||
value={
|
||||
latestTick
|
||||
? `${latestTick.tradeStrength.toFixed(2)}%`
|
||||
: orderBook.anticipatedChangeRate !== undefined
|
||||
: orderBook?.anticipatedChangeRate !== undefined
|
||||
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
|
||||
: "-"
|
||||
}
|
||||
/>
|
||||
<Row label="예상체결가" value={fmt(orderBook.anticipatedPrice ?? 0)} />
|
||||
<Row label="예상체결가" value={fmt(orderBook?.anticipatedPrice ?? 0)} />
|
||||
<Row
|
||||
label="매도1호가"
|
||||
value={latestTick ? fmt(latestTick.askPrice1) : "-"}
|
||||
@@ -546,17 +602,17 @@ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
||||
/** 체결 목록 (Trade Tape) */
|
||||
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
||||
return (
|
||||
<div className="border-t bg-background dark:border-brand-800/45 dark:bg-brand-900/20">
|
||||
<div className="flex h-full min-h-0 flex-col bg-background dark:bg-brand-900/20">
|
||||
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
|
||||
<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]">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div>
|
||||
{ticks.length === 0 && (
|
||||
<div className="flex h-[160px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
|
||||
<div className="flex min-h-[160px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
|
||||
체결 데이터가 아직 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user