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 { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
|
import type {
|
|
|
|
|
DashboardRealtimeTradeTick,
|
|
|
|
|
DashboardStockOrderBookResponse,
|
2026-02-11 16:31:28 +09:00
|
|
|
} from "@/features/trade/types/trade.types";
|
2026-02-10 11:16:39 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { AnimatedQuantity } from "./AnimatedQuantity";
|
|
|
|
|
|
|
|
|
|
// ─── 타입 ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface OrderBookProps {
|
|
|
|
|
symbol?: string;
|
|
|
|
|
referencePrice?: number;
|
|
|
|
|
latestTick: DashboardRealtimeTradeTick | null;
|
|
|
|
|
recentTicks: DashboardRealtimeTradeTick[];
|
|
|
|
|
orderBook: DashboardStockOrderBookResponse | null;
|
|
|
|
|
isLoading?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface BookRow {
|
|
|
|
|
price: number;
|
|
|
|
|
size: number;
|
2026-02-23 15:37:22 +09:00
|
|
|
changeValue: number | null;
|
2026-02-10 11:16:39 +09:00
|
|
|
isHighlighted: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 16:41:10 +09:00
|
|
|
/**
|
|
|
|
|
* @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다.
|
|
|
|
|
* @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다.
|
|
|
|
|
*/
|
2026-02-24 15:43:56 +09:00
|
|
|
function hasOrderBookLevelData(
|
|
|
|
|
levels: DashboardStockOrderBookResponse["levels"],
|
|
|
|
|
) {
|
2026-02-13 16:41:10 +09:00
|
|
|
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호가/잔량/총잔량을 파싱합니다.
|
|
|
|
|
*/
|
2026-02-24 15:43:56 +09:00
|
|
|
function buildFallbackLevelsFromTick(
|
|
|
|
|
latestTick: DashboardRealtimeTradeTick | null,
|
|
|
|
|
) {
|
2026-02-13 16:41:10 +09:00
|
|
|
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),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 11:16:39 +09:00
|
|
|
// ─── 유틸리티 함수 ──────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/** 천단위 구분 포맷 */
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 15:37:22 +09:00
|
|
|
/**
|
|
|
|
|
* @description 기준가 대비 증감값/증감률을 함께 계산합니다.
|
|
|
|
|
* @see features/trade/components/orderbook/OrderBook.tsx buildBookRows
|
|
|
|
|
*/
|
|
|
|
|
function resolvePriceChange(price: number, basePrice: number) {
|
|
|
|
|
if (price <= 0 || basePrice <= 0) {
|
|
|
|
|
return { changeValue: null } as const;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const changeValue = price - basePrice;
|
|
|
|
|
|
|
|
|
|
return { changeValue } as const;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 증감 숫자를 부호 포함 문자열로 포맷합니다.
|
|
|
|
|
* @see features/trade/components/orderbook/OrderBook.tsx BookSideRows
|
|
|
|
|
*/
|
|
|
|
|
function fmtSignedChange(v: number) {
|
|
|
|
|
if (!Number.isFinite(v)) return "-";
|
|
|
|
|
if (v > 0) return `+${fmt(v)}`;
|
|
|
|
|
if (v < 0) return `-${fmt(Math.abs(v))}`;
|
|
|
|
|
return "0";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 증감값에 따라 색상 톤 클래스를 반환합니다.
|
|
|
|
|
* @see features/trade/components/orderbook/OrderBook.tsx BookSideRows
|
|
|
|
|
*/
|
|
|
|
|
function getChangeToneClass(
|
|
|
|
|
changeValue: number | null,
|
|
|
|
|
neutralClass = "text-muted-foreground",
|
|
|
|
|
) {
|
|
|
|
|
if (changeValue === null) {
|
|
|
|
|
return neutralClass;
|
|
|
|
|
}
|
|
|
|
|
if (changeValue > 0) {
|
|
|
|
|
return "text-red-500";
|
|
|
|
|
}
|
|
|
|
|
if (changeValue < 0) {
|
|
|
|
|
return "text-blue-600 dark:text-blue-400";
|
|
|
|
|
}
|
|
|
|
|
return neutralClass;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 11:16:39 +09:00
|
|
|
/** 체결 시각 포맷 */
|
|
|
|
|
function fmtTime(hms: string) {
|
|
|
|
|
if (!hms || hms.length !== 6) return "--:--:--";
|
|
|
|
|
return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 10:24:03 +09:00
|
|
|
/**
|
|
|
|
|
* @description 체결 틱 데이터에서 체결 주도 방향(매수/매도/중립)을 계산합니다.
|
|
|
|
|
* @see features/trade/components/orderbook/OrderBook.tsx TradeTape 체결량 글자색 결정에 사용합니다.
|
|
|
|
|
*/
|
|
|
|
|
function resolveTickExecutionSide(
|
|
|
|
|
tick: DashboardRealtimeTradeTick,
|
|
|
|
|
olderTick?: DashboardRealtimeTradeTick,
|
|
|
|
|
) {
|
|
|
|
|
// 실시간 체결구분 코드(CNTG_CLS_CODE) 우선 해석
|
|
|
|
|
const executionClassCode = (tick.executionClassCode ?? "").trim();
|
|
|
|
|
if (executionClassCode === "1" || executionClassCode === "2") {
|
|
|
|
|
return "buy" as const;
|
|
|
|
|
}
|
|
|
|
|
if (executionClassCode === "4" || executionClassCode === "5") {
|
|
|
|
|
return "sell" as const;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 누적 건수 기반 데이터는 절대값이 아니라 "증분"으로 판단해야 편향을 줄일 수 있습니다.
|
|
|
|
|
if (olderTick) {
|
|
|
|
|
const netBuyDelta =
|
|
|
|
|
tick.netBuyExecutionCount - olderTick.netBuyExecutionCount;
|
|
|
|
|
if (netBuyDelta > 0) return "buy" as const;
|
|
|
|
|
if (netBuyDelta < 0) return "sell" as const;
|
|
|
|
|
|
|
|
|
|
const buyCountDelta = tick.buyExecutionCount - olderTick.buyExecutionCount;
|
|
|
|
|
const sellCountDelta =
|
|
|
|
|
tick.sellExecutionCount - olderTick.sellExecutionCount;
|
|
|
|
|
if (buyCountDelta > sellCountDelta) return "buy" as const;
|
|
|
|
|
if (buyCountDelta < sellCountDelta) return "sell" as const;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (tick.askPrice1 > 0 && tick.bidPrice1 > 0) {
|
|
|
|
|
if (tick.price >= tick.askPrice1 && tick.price > tick.bidPrice1) {
|
|
|
|
|
return "buy" as const;
|
|
|
|
|
}
|
|
|
|
|
if (tick.price <= tick.bidPrice1 && tick.price < tick.askPrice1) {
|
|
|
|
|
return "sell" as const;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (tick.tradeStrength > 100) return "buy" as const;
|
|
|
|
|
if (tick.tradeStrength < 100) return "sell" as const;
|
|
|
|
|
|
|
|
|
|
return "neutral" as const;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 15:37:22 +09:00
|
|
|
/**
|
|
|
|
|
* @description 호가 레벨을 화면 렌더링용 행 모델로 변환합니다.
|
|
|
|
|
* UI 흐름: OrderBook(levels/basePrice/latestPrice) -> buildBookRows -> BookSideRows -> 가격/증감 반영
|
|
|
|
|
* @see features/trade/components/orderbook/OrderBook.tsx OrderBook askRows/bidRows 계산
|
|
|
|
|
*/
|
|
|
|
|
function buildBookRows({
|
|
|
|
|
levels,
|
|
|
|
|
side,
|
|
|
|
|
basePrice,
|
|
|
|
|
latestPrice,
|
|
|
|
|
}: {
|
|
|
|
|
levels: DashboardStockOrderBookResponse["levels"];
|
|
|
|
|
side: "ask" | "bid";
|
|
|
|
|
basePrice: number;
|
|
|
|
|
latestPrice: number;
|
|
|
|
|
}) {
|
|
|
|
|
const normalizedLevels = side === "ask" ? [...levels].reverse() : levels;
|
|
|
|
|
|
|
|
|
|
return normalizedLevels.map((level) => {
|
|
|
|
|
const price = side === "ask" ? level.askPrice : level.bidPrice;
|
|
|
|
|
const size = side === "ask" ? level.askSize : level.bidSize;
|
|
|
|
|
const { changeValue } = resolvePriceChange(price, basePrice);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
price,
|
|
|
|
|
size: Math.max(size, 0),
|
|
|
|
|
changeValue,
|
|
|
|
|
isHighlighted: latestPrice > 0 && price === latestPrice,
|
|
|
|
|
} satisfies BookRow;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 호가/체결 증감 표시용 기준가(전일종가)를 결정합니다.
|
|
|
|
|
* @summary UI 흐름: OrderBook props -> resolveReferencePrice -> 호가행/체결가 증감 계산 반영
|
|
|
|
|
* @see features/trade/components/orderbook/OrderBook.tsx basePrice 계산
|
|
|
|
|
*/
|
|
|
|
|
function resolveReferencePrice({
|
|
|
|
|
referencePrice,
|
|
|
|
|
latestTick,
|
|
|
|
|
}: {
|
|
|
|
|
referencePrice?: number;
|
|
|
|
|
latestTick: DashboardRealtimeTradeTick | null;
|
|
|
|
|
}) {
|
|
|
|
|
if ((referencePrice ?? 0) > 0) {
|
|
|
|
|
return referencePrice!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// referencePrice 미전달 케이스에서도 틱 데이터(price-change)로 전일종가를 역산합니다.
|
|
|
|
|
if (latestTick?.price && Number.isFinite(latestTick.change)) {
|
|
|
|
|
const derivedPrevClose = latestTick.price - latestTick.change;
|
|
|
|
|
if (derivedPrevClose > 0) {
|
|
|
|
|
return derivedPrevClose;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 11:16:39 +09:00
|
|
|
// ─── 메인 컴포넌트 ──────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다.
|
|
|
|
|
*/
|
|
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
const levels = useMemo(() => {
|
|
|
|
|
if (hasOrderBookLevelData(realtimeLevels)) return realtimeLevels;
|
|
|
|
|
return fallbackLevelsFromTick;
|
|
|
|
|
}, [fallbackLevelsFromTick, realtimeLevels]);
|
|
|
|
|
const isTickFallbackActive =
|
|
|
|
|
!hasOrderBookLevelData(realtimeLevels) && fallbackLevelsFromTick.length > 0;
|
2026-02-10 11:16:39 +09:00
|
|
|
|
|
|
|
|
// 체결가: tick에서 우선, 없으면 0
|
|
|
|
|
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
|
|
|
|
|
|
|
|
// 매도호가 (역순: 10호가 → 1호가)
|
|
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 매수호가 (1호가 → 10호가)
|
|
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const askMax = Math.max(1, ...askRows.map((r) => r.size));
|
|
|
|
|
const bidMax = Math.max(1, ...bidRows.map((r) => r.size));
|
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
|
|
|
|
|
|
|
|
// 스프레드·수급 불균형
|
|
|
|
|
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;
|
2026-02-13 16:41:10 +09:00
|
|
|
const totalAsk =
|
|
|
|
|
orderBook?.totalAskSize && orderBook.totalAskSize > 0
|
|
|
|
|
? orderBook.totalAskSize
|
|
|
|
|
: (latestTick?.totalAskSize ?? 0);
|
|
|
|
|
const totalBid =
|
|
|
|
|
orderBook?.totalBidSize && orderBook.totalBidSize > 0
|
|
|
|
|
? orderBook.totalBidSize
|
|
|
|
|
: (latestTick?.totalBidSize ?? 0);
|
2026-02-10 11:16:39 +09:00
|
|
|
const imbalance =
|
|
|
|
|
totalAsk + totalBid > 0
|
|
|
|
|
? ((totalBid - totalAsk) / (totalAsk + totalBid)) * 100
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
|
|
|
// 체결가 행 중앙 스크롤
|
|
|
|
|
|
|
|
|
|
// ─── 빈/로딩 상태 ───
|
|
|
|
|
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-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>
|
|
|
|
|
|
|
|
|
|
{/* ── 일반호가 탭 ── */}
|
|
|
|
|
<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
|
|
|
<ScrollArea className="min-h-0 flex-1">
|
2026-02-13 16:41:10 +09:00
|
|
|
{/* 매도호가 */}
|
|
|
|
|
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
|
|
|
|
|
|
|
|
|
{/* 중앙 바: 현재 체결가 */}
|
2026-02-24 15:43:56 +09:00
|
|
|
<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">
|
2026-02-13 16:41:10 +09:00
|
|
|
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
|
|
|
|
</div>
|
2026-02-24 15:43:56 +09:00
|
|
|
<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",
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-02-13 16:41:10 +09:00
|
|
|
{latestPrice > 0
|
|
|
|
|
? fmt(latestPrice)
|
|
|
|
|
: bestAsk > 0
|
|
|
|
|
? fmt(bestAsk)
|
|
|
|
|
: "-"}
|
|
|
|
|
</span>
|
|
|
|
|
{latestPrice > 0 && basePrice > 0 && (
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
2026-02-24 15:43:56 +09:00
|
|
|
"text-[11px] font-semibold leading-none",
|
2026-02-13 16:41:10 +09:00
|
|
|
latestPrice >= basePrice
|
|
|
|
|
? "text-red-500"
|
|
|
|
|
: "text-blue-600 dark:text-blue-400",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{fmtPct(pctChange(latestPrice, basePrice))}
|
2026-02-10 11:16:39 +09:00
|
|
|
</span>
|
2026-02-13 16:41:10 +09:00
|
|
|
)}
|
2026-02-10 11:16:39 +09:00
|
|
|
</div>
|
2026-02-24 15:43:56 +09:00
|
|
|
<div className="px-2 text-left text-[10px] font-semibold text-blue-600 dark:text-blue-400">
|
2026-02-13 16:41:10 +09:00
|
|
|
{totalBid > 0 ? fmt(totalBid) : ""}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-10 11:16:39 +09:00
|
|
|
|
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
|
|
|
{/* 체결량 영역 */}
|
2026-02-13 16:41:10 +09:00
|
|
|
<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">
|
2026-02-24 15:43:56 +09:00
|
|
|
<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-[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>
|
2026-02-13 16:41:10 +09:00
|
|
|
</div>
|
2026-02-10 11:16:39 +09:00
|
|
|
</div>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
{/* ── 누적호가 탭 ── */}
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
{/* ── 호가주문 탭 ── */}
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── 하위 컴포넌트 ──────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/** 호가 표 헤더 */
|
|
|
|
|
function BookHeader() {
|
|
|
|
|
return (
|
2026-02-24 15:43:56 +09:00
|
|
|
<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>
|
2026-02-10 11:16:39 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 매도 또는 매수 호가 행 목록 */
|
|
|
|
|
function BookSideRows({
|
|
|
|
|
rows,
|
|
|
|
|
side,
|
|
|
|
|
maxSize,
|
|
|
|
|
}: {
|
|
|
|
|
rows: BookRow[];
|
|
|
|
|
side: "ask" | "bid";
|
|
|
|
|
maxSize: number;
|
|
|
|
|
}) {
|
|
|
|
|
const isAsk = side === "ask";
|
|
|
|
|
|
|
|
|
|
return (
|
2026-02-11 14:06:06 +09:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
isAsk
|
2026-02-24 15:43:56 +09:00
|
|
|
? "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",
|
2026-02-11 14:06:06 +09:00
|
|
|
)}
|
|
|
|
|
>
|
2026-02-10 11:16:39 +09:00
|
|
|
{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(
|
2026-02-24 15:43:56 +09:00
|
|
|
"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",
|
2026-02-10 11:16:39 +09:00
|
|
|
row.isHighlighted &&
|
2026-02-24 15:43:56 +09:00
|
|
|
"ring-1 ring-inset ring-amber-400 bg-amber-100/60 dark:bg-amber-800/35",
|
2026-02-10 11:16:39 +09:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{/* 매도잔량 (좌측) */}
|
|
|
|
|
<div className="relative flex items-center justify-end overflow-hidden px-2">
|
|
|
|
|
{isAsk && (
|
|
|
|
|
<>
|
|
|
|
|
<DepthBar ratio={ratio} side="ask" />
|
2026-02-12 10:24:03 +09:00
|
|
|
{row.size > 0 ? (
|
|
|
|
|
<AnimatedQuantity
|
|
|
|
|
value={row.size}
|
|
|
|
|
format={fmt}
|
|
|
|
|
useColor
|
|
|
|
|
side="ask"
|
|
|
|
|
className="relative z-10"
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="relative z-10 text-transparent">0</span>
|
|
|
|
|
)}
|
2026-02-10 11:16:39 +09:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 호가 (중앙) */}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
|
|
|
|
|
row.isHighlighted &&
|
2026-02-11 14:06:06 +09:00
|
|
|
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20",
|
2026-02-10 11:16:39 +09:00
|
|
|
)}
|
|
|
|
|
>
|
2026-02-11 15:27:03 +09:00
|
|
|
<span
|
2026-02-24 15:43:56 +09:00
|
|
|
className={cn(
|
|
|
|
|
"text-[12px] xl:text-[13px]",
|
|
|
|
|
isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400",
|
|
|
|
|
)}
|
2026-02-11 15:27:03 +09:00
|
|
|
>
|
2026-02-10 11:16:39 +09:00
|
|
|
{row.price > 0 ? fmt(row.price) : "-"}
|
|
|
|
|
</span>
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
2026-02-24 15:43:56 +09:00
|
|
|
"w-[50px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
|
2026-02-23 15:37:22 +09:00
|
|
|
getChangeToneClass(row.changeValue),
|
2026-02-10 11:16:39 +09:00
|
|
|
)}
|
|
|
|
|
>
|
2026-02-24 15:43:56 +09:00
|
|
|
{row.changeValue === null
|
|
|
|
|
? "-"
|
|
|
|
|
: fmtSignedChange(row.changeValue)}
|
2026-02-10 11:16:39 +09:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 매수잔량 (우측) */}
|
|
|
|
|
<div className="relative flex items-center justify-start overflow-hidden px-1">
|
|
|
|
|
{!isAsk && (
|
|
|
|
|
<>
|
|
|
|
|
<DepthBar ratio={ratio} side="bid" />
|
2026-02-12 10:24:03 +09:00
|
|
|
{row.size > 0 ? (
|
|
|
|
|
<AnimatedQuantity
|
|
|
|
|
value={row.size}
|
|
|
|
|
format={fmt}
|
|
|
|
|
useColor
|
|
|
|
|
side="bid"
|
|
|
|
|
className="relative z-10"
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="relative z-10 text-transparent">0</span>
|
|
|
|
|
)}
|
2026-02-10 11:16:39 +09:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 우측 요약 패널 */
|
|
|
|
|
function SummaryPanel({
|
|
|
|
|
orderBook,
|
|
|
|
|
latestTick,
|
|
|
|
|
spread,
|
|
|
|
|
imbalance,
|
|
|
|
|
totalAsk,
|
|
|
|
|
totalBid,
|
|
|
|
|
}: {
|
2026-02-13 16:41:10 +09:00
|
|
|
orderBook: DashboardStockOrderBookResponse | null;
|
2026-02-10 11:16:39 +09:00
|
|
|
latestTick: DashboardRealtimeTradeTick | null;
|
|
|
|
|
spread: number;
|
|
|
|
|
imbalance: number;
|
|
|
|
|
totalAsk: number;
|
|
|
|
|
totalBid: number;
|
|
|
|
|
}) {
|
2026-02-23 15:37:22 +09:00
|
|
|
const displayTradeVolume =
|
|
|
|
|
latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0
|
|
|
|
|
? (orderBook?.anticipatedVolume ?? 0)
|
|
|
|
|
: (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0);
|
2026-02-24 15:43:56 +09:00
|
|
|
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",
|
|
|
|
|
},
|
|
|
|
|
];
|
2026-02-23 15:37:22 +09:00
|
|
|
|
2026-02-10 11:16:39 +09:00
|
|
|
return (
|
2026-02-24 15:43:56 +09:00
|
|
|
<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>
|
2026-02-10 11:16:39 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 15:43:56 +09:00
|
|
|
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({
|
2026-02-10 11:16:39 +09:00
|
|
|
label,
|
|
|
|
|
value,
|
|
|
|
|
tone,
|
|
|
|
|
}: {
|
|
|
|
|
label: string;
|
|
|
|
|
value: string;
|
|
|
|
|
tone?: "ask" | "bid";
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
2026-02-24 15:43:56 +09:00
|
|
|
<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">
|
2026-02-11 15:27:03 +09:00
|
|
|
{label}
|
|
|
|
|
</span>
|
2026-02-10 11:16:39 +09:00
|
|
|
<span
|
|
|
|
|
className={cn(
|
2026-02-24 15:43:56 +09:00
|
|
|
"shrink-0 text-xs font-semibold tabular-nums",
|
2026-02-10 11:16:39 +09:00
|
|
|
tone === "ask" && "text-red-600",
|
2026-02-11 14:06:06 +09:00
|
|
|
tone === "bid" && "text-blue-600 dark:text-blue-400",
|
2026-02-10 11:16:39 +09:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{value}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 잔량 깊이 바 */
|
|
|
|
|
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
|
|
|
|
if (ratio <= 0) return null;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-02-24 15:43:56 +09:00
|
|
|
"absolute inset-y-0.5 z-0 rounded-sm transition-[width] duration-150",
|
2026-02-11 14:06:06 +09:00
|
|
|
side === "ask"
|
2026-02-24 15:43:56 +09:00
|
|
|
? "right-0.5 bg-red-300/55 dark:bg-red-700/50"
|
|
|
|
|
: "left-0.5 bg-blue-300/60 dark:bg-blue-600/45",
|
2026-02-10 11:16:39 +09:00
|
|
|
)}
|
|
|
|
|
style={{ width: `${ratio}%` }}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 체결 목록 (Trade Tape) */
|
2026-02-24 15:43:56 +09:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-10 11:16:39 +09:00
|
|
|
return (
|
2026-02-24 15:43:56 +09:00
|
|
|
<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">
|
2026-02-10 11:16:39 +09:00
|
|
|
<div className="flex items-center">체결시각</div>
|
|
|
|
|
<div className="flex items-center justify-end">체결가</div>
|
|
|
|
|
<div className="flex items-center justify-end">체결량</div>
|
|
|
|
|
</div>
|
2026-02-24 15:43:56 +09:00
|
|
|
{shouldUseScrollableList ? (
|
|
|
|
|
<ScrollArea className="min-h-0 flex-1">{tapeRows}</ScrollArea>
|
|
|
|
|
) : (
|
|
|
|
|
tapeRows
|
|
|
|
|
)}
|
2026-02-10 11:16:39 +09:00
|
|
|
</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}
|
2026-02-11 14:06:06 +09:00
|
|
|
className="grid h-7 grid-cols-3 items-center rounded border bg-background px-2 text-xs dark:border-brand-800/45 dark:bg-black/20"
|
2026-02-10 11:16:39 +09:00
|
|
|
>
|
|
|
|
|
<span className="tabular-nums text-red-600">{fmt(r.askAcc)}</span>
|
|
|
|
|
<span className="text-center font-medium tabular-nums">
|
|
|
|
|
{fmt(r.price)}
|
|
|
|
|
</span>
|
2026-02-11 14:06:06 +09:00
|
|
|
<span className="text-right tabular-nums text-blue-600 dark:text-blue-400">
|
2026-02-10 11:16:39 +09:00
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
}
|