2026-02-10 11:16:39 +09:00
|
|
|
import { useMemo } from "react";
|
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
|
|
|
import type {
|
|
|
|
|
DashboardRealtimeTradeTick,
|
|
|
|
|
DashboardStockOrderBookResponse,
|
2026-02-11 16:31:28 +09:00
|
|
|
} from "@/features/trade/types/trade.types";
|
2026-02-26 09:05:17 +09:00
|
|
|
import type { BookRow } from "./orderbook-utils";
|
|
|
|
|
import {
|
|
|
|
|
buildBookRows,
|
|
|
|
|
buildFallbackLevelsFromTick,
|
|
|
|
|
hasOrderBookLevelData,
|
|
|
|
|
resolveReferencePrice,
|
|
|
|
|
} from "./orderbook-utils";
|
|
|
|
|
import {
|
|
|
|
|
BookHeader,
|
|
|
|
|
BookSideRows,
|
|
|
|
|
CumulativeRows,
|
|
|
|
|
CurrentPriceBar,
|
|
|
|
|
OrderBookSkeleton,
|
|
|
|
|
SummaryPanel,
|
|
|
|
|
TradeTape,
|
|
|
|
|
} from "./orderbook-sections";
|
2026-02-10 11:16:39 +09:00
|
|
|
|
|
|
|
|
interface OrderBookProps {
|
|
|
|
|
symbol?: string;
|
|
|
|
|
referencePrice?: number;
|
|
|
|
|
latestTick: DashboardRealtimeTradeTick | null;
|
|
|
|
|
recentTicks: DashboardRealtimeTradeTick[];
|
|
|
|
|
orderBook: DashboardStockOrderBookResponse | null;
|
|
|
|
|
isLoading?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-26 09:05:17 +09:00
|
|
|
* @description 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다.
|
|
|
|
|
* @see features/trade/components/orderbook/orderbook-utils.ts 호가 계산/포맷 유틸
|
|
|
|
|
* @see features/trade/components/orderbook/orderbook-sections.tsx 호가/체결/요약 UI 섹션
|
2026-02-10 11:16:39 +09:00
|
|
|
*/
|
|
|
|
|
export function OrderBook({
|
|
|
|
|
symbol,
|
|
|
|
|
referencePrice,
|
|
|
|
|
latestTick,
|
|
|
|
|
recentTicks,
|
|
|
|
|
orderBook,
|
|
|
|
|
isLoading,
|
|
|
|
|
}: OrderBookProps) {
|
2026-02-13 16:41:10 +09:00
|
|
|
const realtimeLevels = useMemo(() => orderBook?.levels ?? [], [orderBook]);
|
|
|
|
|
const fallbackLevelsFromTick = useMemo(
|
|
|
|
|
() => buildFallbackLevelsFromTick(latestTick),
|
|
|
|
|
[latestTick],
|
|
|
|
|
);
|
2026-02-26 09:05:17 +09:00
|
|
|
const hasRealtimeLevelData = useMemo(
|
|
|
|
|
() => hasOrderBookLevelData(realtimeLevels),
|
|
|
|
|
[realtimeLevels],
|
|
|
|
|
);
|
2026-02-13 16:41:10 +09:00
|
|
|
const levels = useMemo(() => {
|
2026-02-26 09:05:17 +09:00
|
|
|
if (hasRealtimeLevelData) return realtimeLevels;
|
2026-02-13 16:41:10 +09:00
|
|
|
return fallbackLevelsFromTick;
|
2026-02-26 09:05:17 +09:00
|
|
|
}, [fallbackLevelsFromTick, hasRealtimeLevelData, realtimeLevels]);
|
|
|
|
|
|
2026-02-13 16:41:10 +09:00
|
|
|
const isTickFallbackActive =
|
2026-02-26 09:05:17 +09:00
|
|
|
!hasRealtimeLevelData && fallbackLevelsFromTick.length > 0;
|
2026-02-10 11:16:39 +09:00
|
|
|
|
|
|
|
|
const latestPrice =
|
|
|
|
|
latestTick?.price && latestTick.price > 0 ? latestTick.price : 0;
|
|
|
|
|
|
2026-02-23 15:37:22 +09:00
|
|
|
const basePrice = resolveReferencePrice({ referencePrice, latestTick });
|
2026-02-10 11:16:39 +09:00
|
|
|
|
|
|
|
|
const askRows: BookRow[] = useMemo(
|
|
|
|
|
() =>
|
2026-02-23 15:37:22 +09:00
|
|
|
buildBookRows({
|
|
|
|
|
levels,
|
|
|
|
|
side: "ask",
|
|
|
|
|
basePrice,
|
|
|
|
|
latestPrice,
|
|
|
|
|
}),
|
2026-02-10 11:16:39 +09:00
|
|
|
[levels, basePrice, latestPrice],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const bidRows: BookRow[] = useMemo(
|
|
|
|
|
() =>
|
2026-02-23 15:37:22 +09:00
|
|
|
buildBookRows({
|
|
|
|
|
levels,
|
|
|
|
|
side: "bid",
|
|
|
|
|
basePrice,
|
|
|
|
|
latestPrice,
|
|
|
|
|
}),
|
2026-02-10 11:16:39 +09:00
|
|
|
[levels, basePrice, latestPrice],
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-26 09:05:17 +09:00
|
|
|
const askMax = useMemo(() => Math.max(1, ...askRows.map((r) => r.size)), [askRows]);
|
|
|
|
|
const bidMax = useMemo(() => Math.max(1, ...bidRows.map((r) => r.size)), [bidRows]);
|
2026-02-24 15:43:56 +09:00
|
|
|
const mobileAskRows = useMemo(() => askRows.slice(0, 6), [askRows]);
|
|
|
|
|
const mobileBidRows = useMemo(() => bidRows.slice(0, 6), [bidRows]);
|
2026-02-10 11:16:39 +09:00
|
|
|
|
2026-02-26 09:05:17 +09:00
|
|
|
const { bestAsk, spread, totalAsk, totalBid, imbalance } = useMemo(() => {
|
|
|
|
|
const resolvedBestAsk = levels.find((level) => level.askPrice > 0)?.askPrice ?? 0;
|
|
|
|
|
const resolvedBestBid = levels.find((level) => level.bidPrice > 0)?.bidPrice ?? 0;
|
|
|
|
|
const resolvedSpread =
|
|
|
|
|
resolvedBestAsk > 0 && resolvedBestBid > 0
|
|
|
|
|
? resolvedBestAsk - resolvedBestBid
|
|
|
|
|
: 0;
|
|
|
|
|
const resolvedTotalAsk =
|
|
|
|
|
orderBook?.totalAskSize && orderBook.totalAskSize > 0
|
|
|
|
|
? orderBook.totalAskSize
|
|
|
|
|
: (latestTick?.totalAskSize ?? 0);
|
|
|
|
|
const resolvedTotalBid =
|
|
|
|
|
orderBook?.totalBidSize && orderBook.totalBidSize > 0
|
|
|
|
|
? orderBook.totalBidSize
|
|
|
|
|
: (latestTick?.totalBidSize ?? 0);
|
|
|
|
|
const resolvedImbalance =
|
|
|
|
|
resolvedTotalAsk + resolvedTotalBid > 0
|
|
|
|
|
? ((resolvedTotalBid - resolvedTotalAsk) /
|
|
|
|
|
(resolvedTotalAsk + resolvedTotalBid)) *
|
|
|
|
|
100
|
|
|
|
|
: 0;
|
2026-02-10 11:16:39 +09:00
|
|
|
|
2026-02-26 09:05:17 +09:00
|
|
|
return {
|
|
|
|
|
bestAsk: resolvedBestAsk,
|
|
|
|
|
spread: resolvedSpread,
|
|
|
|
|
totalAsk: resolvedTotalAsk,
|
|
|
|
|
totalBid: resolvedTotalBid,
|
|
|
|
|
imbalance: resolvedImbalance,
|
|
|
|
|
};
|
|
|
|
|
}, [latestTick?.totalAskSize, latestTick?.totalBidSize, levels, orderBook]);
|
2026-02-10 11:16:39 +09:00
|
|
|
|
|
|
|
|
if (!symbol) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
|
|
|
종목을 선택해주세요.
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-13 16:41:10 +09:00
|
|
|
if (isLoading && !orderBook && fallbackLevelsFromTick.length === 0) {
|
|
|
|
|
return <OrderBookSkeleton />;
|
|
|
|
|
}
|
|
|
|
|
if (!orderBook && fallbackLevelsFromTick.length === 0) {
|
2026-02-10 11:16:39 +09:00
|
|
|
return (
|
|
|
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
|
|
|
호가 정보를 가져오지 못했습니다.
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-02-24 15:43:56 +09:00
|
|
|
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-[linear-gradient(180deg,rgba(13,13,24,0.95),rgba(8,8,18,0.98))]">
|
2026-02-10 11:16:39 +09:00
|
|
|
<Tabs defaultValue="normal" className="h-full min-h-0">
|
2026-02-26 09:05:17 +09:00
|
|
|
{/* ========== ORDERBOOK TAB HEADER ========== */}
|
2026-02-24 15:43:56 +09:00
|
|
|
<div className="border-b border-border/60 bg-muted/15 px-2 pt-2 dark:border-brand-800/50 dark:bg-brand-950/60">
|
2026-02-10 11:16:39 +09:00
|
|
|
<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>
|
|
|
|
|
|
2026-02-26 09:05:17 +09:00
|
|
|
{/* ========== ORDERBOOK NORMAL TAB ========== */}
|
2026-02-10 11:16:39 +09:00
|
|
|
<TabsContent value="normal" className="min-h-0 flex-1">
|
2026-02-24 15:43:56 +09:00
|
|
|
<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)_220px_220px] 2xl:grid-cols-[minmax(0,1fr)_250px_240px] xl:overflow-hidden">
|
2026-02-13 16:41:10 +09:00
|
|
|
<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">
|
2026-02-24 15:43:56 +09:00
|
|
|
시간외 전용 호가(`H0STOAA0`) 미수신 상태입니다.
|
|
|
|
|
체결(`H0UNCNT0`) 1호가 기준으로 표시 중입니다.
|
2026-02-13 16:41:10 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<BookHeader />
|
2026-02-24 15:43:56 +09:00
|
|
|
<div className="xl:hidden">
|
|
|
|
|
<BookSideRows rows={mobileAskRows} side="ask" maxSize={askMax} />
|
|
|
|
|
<CurrentPriceBar
|
|
|
|
|
latestPrice={latestPrice}
|
|
|
|
|
basePrice={basePrice}
|
|
|
|
|
bestAsk={bestAsk}
|
|
|
|
|
totalAsk={totalAsk}
|
|
|
|
|
totalBid={totalBid}
|
|
|
|
|
/>
|
|
|
|
|
<BookSideRows rows={mobileBidRows} side="bid" maxSize={bidMax} />
|
|
|
|
|
</div>
|
|
|
|
|
<ScrollArea className="hidden min-h-0 flex-1 xl:block">
|
2026-02-13 16:41:10 +09:00
|
|
|
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
2026-02-24 15:43:56 +09:00
|
|
|
<CurrentPriceBar
|
|
|
|
|
latestPrice={latestPrice}
|
|
|
|
|
basePrice={basePrice}
|
|
|
|
|
bestAsk={bestAsk}
|
|
|
|
|
totalAsk={totalAsk}
|
|
|
|
|
totalBid={totalBid}
|
|
|
|
|
/>
|
2026-02-13 16:41:10 +09:00
|
|
|
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
|
|
|
|
|
</ScrollArea>
|
2026-02-10 11:16:39 +09:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-24 15:43:56 +09:00
|
|
|
<div className="min-h-[180px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0 xl:border-r">
|
|
|
|
|
<div className="h-full min-h-0">
|
|
|
|
|
<TradeTape ticks={recentTicks} maxRows={10} />
|
|
|
|
|
</div>
|
2026-02-10 11:16:39 +09:00
|
|
|
</div>
|
2026-02-13 16:41:10 +09:00
|
|
|
|
2026-02-24 15:43:56 +09:00
|
|
|
<div className="min-h-[180px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0">
|
|
|
|
|
<div className="h-full min-h-0">
|
|
|
|
|
<SummaryPanel
|
|
|
|
|
orderBook={orderBook}
|
|
|
|
|
latestTick={latestTick}
|
|
|
|
|
spread={spread}
|
|
|
|
|
imbalance={imbalance}
|
|
|
|
|
totalAsk={totalAsk}
|
|
|
|
|
totalBid={totalBid}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-02-13 16:41:10 +09:00
|
|
|
</div>
|
2026-02-10 11:16:39 +09:00
|
|
|
</div>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
2026-02-26 09:05:17 +09:00
|
|
|
{/* ========== ORDERBOOK CUMULATIVE TAB ========== */}
|
2026-02-10 11:16:39 +09:00
|
|
|
<TabsContent value="cumulative" className="min-h-0 flex-1">
|
2026-02-11 14:06:06 +09:00
|
|
|
<ScrollArea className="h-full border-t dark:border-brand-800/45">
|
2026-02-10 11:16:39 +09:00
|
|
|
<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>
|
|
|
|
|
|
2026-02-26 09:05:17 +09:00
|
|
|
{/* ========== ORDERBOOK ORDER TAB ========== */}
|
2026-02-10 11:16:39 +09:00
|
|
|
<TabsContent value="order" className="min-h-0 flex-1">
|
2026-02-11 14:06:06 +09:00
|
|
|
<div className="flex h-full items-center justify-center border-t px-4 text-sm text-muted-foreground dark:border-brand-800/45 dark:text-brand-100/75">
|
2026-02-10 11:16:39 +09:00
|
|
|
호가주문 탭은 주문 입력 패널과 연동해 확장할 수 있습니다.
|
|
|
|
|
</div>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
</Tabs>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|