트레이딩창 UI 배치 및 UX 수정 및 기획서 추가

This commit is contained in:
2026-02-24 15:43:56 +09:00
parent 19ebb1c6ea
commit c53f79a86f
16 changed files with 1553 additions and 450 deletions

View File

@@ -31,7 +31,9 @@ interface BookRow {
* @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다.
* @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다.
*/
function hasOrderBookLevelData(levels: DashboardStockOrderBookResponse["levels"]) {
function hasOrderBookLevelData(
levels: DashboardStockOrderBookResponse["levels"],
) {
return levels.some(
(level) =>
level.askPrice > 0 ||
@@ -45,7 +47,9 @@ function hasOrderBookLevelData(levels: DashboardStockOrderBookResponse["levels"]
* @description H0STOAA0 미수신 상황에서 체결(H0UNCNT0) 데이터로 최소 1호가를 구성합니다.
* @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeTickBatch 체결 데이터에서 1호가/잔량/총잔량을 파싱합니다.
*/
function buildFallbackLevelsFromTick(latestTick: DashboardRealtimeTradeTick | null) {
function buildFallbackLevelsFromTick(
latestTick: DashboardRealtimeTradeTick | null,
) {
if (!latestTick) return [] as DashboardStockOrderBookResponse["levels"];
if (latestTick.askPrice1 <= 0 && latestTick.bidPrice1 <= 0) {
return [] as DashboardStockOrderBookResponse["levels"];
@@ -292,6 +296,8 @@ export function OrderBook({
const askMax = Math.max(1, ...askRows.map((r) => r.size));
const bidMax = Math.max(1, ...bidRows.map((r) => r.size));
const mobileAskRows = useMemo(() => askRows.slice(0, 6), [askRows]);
const mobileBidRows = useMemo(() => bidRows.slice(0, 6), [bidRows]);
// 스프레드·수급 불균형
const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0;
@@ -332,10 +338,10 @@ export function OrderBook({
}
return (
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-brand-900/10">
<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))]">
<Tabs defaultValue="normal" className="h-full min-h-0">
{/* 탭 헤더 */}
<div className="border-b px-2 pt-2 dark:border-brand-800/45 dark:bg-brand-900/28">
<div className="border-b border-border/60 bg-muted/15 px-2 pt-2 dark:border-brand-800/50 dark:bg-brand-950/60">
<TabsList variant="line" className="w-full justify-start">
<TabsTrigger value="normal" className="px-3">
@@ -351,27 +357,36 @@ export function OrderBook({
{/* ── 일반호가 탭 ── */}
<TabsContent value="normal" className="min-h-0 flex-1">
<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 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">
{/* 호가 테이블 */}
<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 .
(`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">
<ScrollArea className="min-h-0 flex-1">
{/* 매도호가 */}
<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">
<div className="grid h-10 grid-cols-3 items-center border-y-2 border-amber-400 bg-linear-to-r from-red-50/60 via-amber-50/90 to-blue-50/60 shadow-[0_0_10px_rgba(251,191,36,0.25)] dark:from-red-950/30 dark:via-amber-900/30 dark:to-blue-950/30 xl:h-10">
<div className="px-2 text-right text-[10px] font-semibold text-red-600 dark:text-red-400">
{totalAsk > 0 ? fmt(totalAsk) : ""}
</div>
<div className="flex items-center justify-center gap-1">
<span className="text-xs font-bold tabular-nums">
<div className="flex flex-col items-center justify-center">
<span
className={cn(
"text-base leading-none font-bold tabular-nums",
latestPrice > 0 && basePrice > 0
? latestPrice >= basePrice
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
: "text-foreground dark:text-brand-50",
)}
>
{latestPrice > 0
? fmt(latestPrice)
: bestAsk > 0
@@ -381,7 +396,7 @@ export function OrderBook({
{latestPrice > 0 && basePrice > 0 && (
<span
className={cn(
"text-[10px] font-medium",
"text-[11px] font-semibold leading-none",
latestPrice >= basePrice
? "text-red-500"
: "text-blue-600 dark:text-blue-400",
@@ -391,7 +406,7 @@ export function OrderBook({
</span>
)}
</div>
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
<div className="px-2 text-left text-[10px] font-semibold text-blue-600 dark:text-blue-400">
{totalBid > 0 ? fmt(totalBid) : ""}
</div>
</div>
@@ -401,21 +416,25 @@ export function OrderBook({
</ScrollArea>
</div>
{/* 체결 목록: 데스크톱에서는 호가 오른쪽, 모바일에서는 아래 */}
{/* 체결량 영역 */}
<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 className="h-full min-h-0">
<TradeTape ticks={recentTicks} maxRows={10} />
</div>
</div>
{/* 우측 요약 패널 */}
<div className="hidden xl:block min-h-0">
<SummaryPanel
orderBook={orderBook}
latestTick={latestTick}
spread={spread}
imbalance={imbalance}
totalAsk={totalAsk}
totalBid={totalBid}
/>
{/* 실시간 정보 영역 */}
<div className="min-h-[220px] 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>
</div>
</div>
</TabsContent>
@@ -450,10 +469,16 @@ export function OrderBook({
/** 호가 표 헤더 */
function BookHeader() {
return (
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 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 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 className="grid h-8 grid-cols-3 border-b bg-linear-to-r from-red-50/40 via-muted/20 to-blue-50/40 text-[11px] font-semibold text-muted-foreground dark:border-brand-800/50 dark:from-red-950/30 dark:via-brand-900/40 dark:to-blue-950/30 dark:text-brand-100/80">
<div className="flex items-center justify-end px-2 text-red-600/80 dark:text-red-400/80">
</div>
<div className="flex items-center justify-center border-x border-border/50 dark:border-brand-800/40">
</div>
<div className="flex items-center justify-start px-2 text-blue-600/80 dark:text-blue-400/80">
</div>
</div>
);
}
@@ -474,8 +499,8 @@ function BookSideRows({
<div
className={cn(
isAsk
? "bg-red-50/20 dark:bg-red-950/18"
: "bg-blue-50/55 dark:bg-blue-950/22",
? "bg-linear-to-r from-red-50/40 via-red-50/10 to-transparent dark:from-red-950/35 dark:via-red-950/10 dark:to-transparent"
: "bg-linear-to-r from-transparent via-blue-50/10 to-blue-50/45 dark:from-transparent dark:via-blue-950/10 dark:to-blue-950/35",
)}
>
{rows.map((row, i) => {
@@ -486,9 +511,9 @@ function BookSideRows({
<div
key={`${side}-${row.price}-${i}`}
className={cn(
"grid h-7 grid-cols-3 border-b border-border/40 text-[11px] xl:h-8 xl:text-xs dark:border-brand-800/35",
"grid h-[29px] grid-cols-3 border-b border-border/30 text-[11px] transition-colors hover:bg-muted/15 xl:h-[29px] xl:text-xs dark:border-brand-800/25 dark:hover:bg-brand-900/20",
row.isHighlighted &&
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-800/30",
"ring-1 ring-inset ring-amber-400 bg-amber-100/60 dark:bg-amber-800/35",
)}
>
{/* 매도잔량 (좌측) */}
@@ -520,19 +545,22 @@ function BookSideRows({
)}
>
<span
className={
isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400"
}
className={cn(
"text-[12px] xl:text-[13px]",
isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400",
)}
>
{row.price > 0 ? fmt(row.price) : "-"}
</span>
<span
className={cn(
"w-[58px] shrink-0 text-right text-[10px] tabular-nums",
"w-[50px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
getChangeToneClass(row.changeValue),
)}
>
{row.changeValue === null ? "-" : fmtSignedChange(row.changeValue)}
{row.changeValue === null
? "-"
: fmtSignedChange(row.changeValue)}
</span>
</div>
@@ -582,71 +610,80 @@ function SummaryPanel({
latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0
? (orderBook?.anticipatedVolume ?? 0)
: (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0);
const summaryItems: SummaryMetric[] = [
{
label: "실시간",
value: orderBook || latestTick ? "연결됨" : "끊김",
tone: orderBook || latestTick ? "bid" : undefined,
},
{ label: "거래량", value: fmt(displayTradeVolume) },
{
label: "누적거래량",
value: fmt(
latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0,
),
},
{
label: "체결강도",
value: latestTick
? `${latestTick.tradeStrength.toFixed(2)}%`
: orderBook?.anticipatedChangeRate !== undefined
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
: "-",
},
{ label: "예상체결가", value: fmt(orderBook?.anticipatedPrice ?? 0) },
{
label: "매도1호가",
value: latestTick ? fmt(latestTick.askPrice1) : "-",
tone: "ask",
},
{
label: "매수1호가",
value: latestTick ? fmt(latestTick.bidPrice1) : "-",
tone: "bid",
},
{
label: "순매수체결",
value: latestTick ? fmt(latestTick.netBuyExecutionCount) : "-",
},
{ label: "총 매도잔량", value: fmt(totalAsk), tone: "ask" },
{ label: "총 매수잔량", value: fmt(totalBid), tone: "bid" },
{ label: "스프레드", value: fmt(spread) },
{
label: "수급 불균형",
value: `${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`,
tone: imbalance >= 0 ? "bid" : "ask",
},
];
return (
<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 || latestTick ? "연결됨" : "끊김"}
tone={orderBook || latestTick ? "bid" : undefined}
/>
<Row
label="거래량"
value={fmt(displayTradeVolume)}
/>
<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 className="h-full min-w-0 bg-muted/10 p-2 text-xs dark:bg-brand-900/30">
<div className="grid h-full grid-cols-1 grid-rows-12 gap-1">
{summaryItems.map((item) => (
<SummaryMetricCell
key={item.label}
label={item.label}
value={item.value}
tone={item.tone}
/>
))}
</div>
</div>
);
}
/** 요약 패널 단일 행 */
function Row({
interface SummaryMetric {
label: string;
value: string;
tone?: "ask" | "bid";
}
/**
* @description 실시간 요약 패널의 2열 메트릭 셀을 렌더링합니다.
* @summary UI 흐름: SummaryPanel -> SummaryMetricCell -> 체결량 하단 정보 패널을 압축해 스크롤 없이 표시
* @see features/trade/components/orderbook/OrderBook.tsx SummaryPanel summaryItems
*/
function SummaryMetricCell({
label,
value,
tone,
@@ -656,13 +693,13 @@ function Row({
tone?: "ask" | "bid";
}) {
return (
<div className="mb-1.5 flex items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
<span className="min-w-0 truncate text-muted-foreground dark:text-brand-100/70">
<div className="flex h-full min-w-0 items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
<span className="truncate text-[11px] text-muted-foreground dark:text-brand-100/70">
{label}
</span>
<span
className={cn(
"shrink-0 font-medium tabular-nums",
"shrink-0 text-xs font-semibold tabular-nums",
tone === "ask" && "text-red-600",
tone === "bid" && "text-blue-600 dark:text-blue-400",
)}
@@ -679,10 +716,10 @@ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
return (
<div
className={cn(
"absolute inset-y-1 z-0 rounded-sm",
"absolute inset-y-0.5 z-0 rounded-sm transition-[width] duration-150",
side === "ask"
? "right-1 bg-red-200/50 dark:bg-red-800/40"
: "left-1 bg-blue-200/55 dark:bg-blue-500/35",
? "right-0.5 bg-red-300/55 dark:bg-red-700/50"
: "left-0.5 bg-blue-300/60 dark:bg-blue-600/45",
)}
style={{ width: `${ratio}%` }}
/>
@@ -690,65 +727,79 @@ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
}
/** 체결 목록 (Trade Tape) */
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
function TradeTape({
ticks,
maxRows,
}: {
ticks: DashboardRealtimeTradeTick[];
maxRows?: number;
}) {
const visibleTicks = typeof maxRows === "number" ? ticks.slice(0, maxRows) : ticks;
const shouldUseScrollableList = typeof maxRows !== "number";
const tapeRows = (
<div>
{visibleTicks.length === 0 && (
<div className="flex min-h-[140px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
.
</div>
)}
{visibleTicks.map((t, i) => {
const olderTick = visibleTicks[i + 1];
const executionSide = resolveTickExecutionSide(t, olderTick);
// UI 흐름: 체결목록 UI -> resolveTickExecutionSide(현재/이전 틱) -> 색상 class 결정 -> 체결량 렌더 반영
const volumeToneClass =
executionSide === "buy"
? "text-red-600"
: executionSide === "sell"
? "text-blue-600 dark:text-blue-400"
: "text-muted-foreground dark:text-brand-100/70";
return (
<div
key={`${t.tickTime}-${t.price}-${i}`}
className="grid h-7 grid-cols-3 border-b border-border/40 px-2 text-[13px] dark:border-brand-800/35"
>
<div className="flex items-center tabular-nums">
{fmtTime(t.tickTime)}
</div>
<div
className={cn(
"flex items-center justify-end tabular-nums",
getChangeToneClass(
t.change,
"text-foreground dark:text-brand-50",
),
)}
>
{fmt(t.price)}
</div>
<div
className={cn(
"flex items-center justify-end tabular-nums",
volumeToneClass,
)}
>
{fmt(t.tradeVolume)}
</div>
</div>
);
})}
</div>
);
return (
<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 min-h-0 flex-col bg-background dark:bg-brand-900/20">
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 px-2 text-xs 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="min-h-0 flex-1">
<div>
{ticks.length === 0 && (
<div className="flex min-h-[160px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
.
</div>
)}
{ticks.map((t, i) => {
const olderTick = ticks[i + 1];
const executionSide = resolveTickExecutionSide(t, olderTick);
// UI 흐름: 체결목록 UI -> resolveTickExecutionSide(현재/이전 틱) -> 색상 class 결정 -> 체결량 렌더 반영
const volumeToneClass =
executionSide === "buy"
? "text-red-600"
: executionSide === "sell"
? "text-blue-600 dark:text-blue-400"
: "text-muted-foreground dark:text-brand-100/70";
return (
<div
key={`${t.tickTime}-${t.price}-${i}`}
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs dark:border-brand-800/35"
>
<div className="flex items-center tabular-nums">
{fmtTime(t.tickTime)}
</div>
<div
className={cn(
"flex items-center justify-end tabular-nums",
getChangeToneClass(t.change, "text-foreground dark:text-brand-50"),
)}
>
{fmt(t.price)}
</div>
<div
className={cn(
"flex items-center justify-end tabular-nums",
volumeToneClass,
)}
>
{fmt(t.tradeVolume)}
</div>
<div className="flex items-center justify-end tabular-nums">
{t.tradeStrength.toFixed(2)}%
</div>
</div>
);
})}
</div>
</ScrollArea>
{shouldUseScrollableList ? (
<ScrollArea className="min-h-0 flex-1">{tapeRows}</ScrollArea>
) : (
tapeRows
)}
</div>
);
}