실시간 웹소켓 리팩토링

This commit is contained in:
2026-02-23 15:37:22 +09:00
parent 276ef09d89
commit 19ebb1c6ea
10 changed files with 510 additions and 96 deletions

View File

@@ -14,7 +14,6 @@ import { AnimatedQuantity } from "./AnimatedQuantity";
interface OrderBookProps {
symbol?: string;
referencePrice?: number;
currentPrice?: number;
latestTick: DashboardRealtimeTradeTick | null;
recentTicks: DashboardRealtimeTradeTick[];
orderBook: DashboardStockOrderBookResponse | null;
@@ -24,7 +23,7 @@ interface OrderBookProps {
interface BookRow {
price: number;
size: number;
changePercent: number | null;
changeValue: number | null;
isHighlighted: boolean;
}
@@ -79,6 +78,51 @@ function pctChange(price: number, base: number) {
return base > 0 ? ((price - base) / base) * 100 : 0;
}
/**
* @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;
}
/** 체결 시각 포맷 */
function fmtTime(hms: string) {
if (!hms || hms.length !== 6) return "--:--:--";
@@ -131,6 +175,65 @@ function resolveTickExecutionSide(
return "neutral" as const;
}
/**
* @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;
}
// ─── 메인 컴포넌트 ──────────────────────────────────────
/**
@@ -139,7 +242,6 @@ function resolveTickExecutionSide(
export function OrderBook({
symbol,
referencePrice,
currentPrice,
latestTick,
recentTicks,
orderBook,
@@ -162,42 +264,29 @@ export function OrderBook({
latestTick?.price && latestTick.price > 0 ? latestTick.price : 0;
// 등락률 기준가
const basePrice =
(referencePrice ?? 0) > 0
? referencePrice!
: (currentPrice ?? 0) > 0
? currentPrice!
: latestPrice > 0
? latestPrice
: 0;
const basePrice = resolveReferencePrice({ referencePrice, latestTick });
// 매도호가 (역순: 10호가 → 1호가)
const askRows: BookRow[] = useMemo(
() =>
[...levels].reverse().map((l) => ({
price: l.askPrice,
size: Math.max(l.askSize, 0),
changePercent:
l.askPrice > 0 && basePrice > 0
? pctChange(l.askPrice, basePrice)
: null,
isHighlighted: latestPrice > 0 && l.askPrice === latestPrice,
})),
buildBookRows({
levels,
side: "ask",
basePrice,
latestPrice,
}),
[levels, basePrice, latestPrice],
);
// 매수호가 (1호가 → 10호가)
const bidRows: BookRow[] = useMemo(
() =>
levels.map((l) => ({
price: l.bidPrice,
size: Math.max(l.bidSize, 0),
changePercent:
l.bidPrice > 0 && basePrice > 0
? pctChange(l.bidPrice, basePrice)
: null,
isHighlighted: latestPrice > 0 && l.bidPrice === latestPrice,
})),
buildBookRows({
levels,
side: "bid",
basePrice,
latestPrice,
}),
[levels, basePrice, latestPrice],
);
@@ -439,15 +528,11 @@ function BookSideRows({
</span>
<span
className={cn(
"text-[10px]",
row.changePercent !== null
? row.changePercent >= 0
? "text-red-500"
: "text-blue-600 dark:text-blue-400"
: "text-muted-foreground",
"w-[58px] shrink-0 text-right text-[10px] tabular-nums",
getChangeToneClass(row.changeValue),
)}
>
{row.changePercent === null ? "-" : fmtPct(row.changePercent)}
{row.changeValue === null ? "-" : fmtSignedChange(row.changeValue)}
</span>
</div>
@@ -493,6 +578,11 @@ function SummaryPanel({
totalAsk: number;
totalBid: number;
}) {
const displayTradeVolume =
latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0
? (orderBook?.anticipatedVolume ?? 0)
: (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0);
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
@@ -502,7 +592,7 @@ function SummaryPanel({
/>
<Row
label="거래량"
value={fmt(latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0)}
value={fmt(displayTradeVolume)}
/>
<Row
label="누적거래량"
@@ -635,7 +725,12 @@ function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
<div className="flex items-center tabular-nums">
{fmtTime(t.tickTime)}
</div>
<div className="flex items-center justify-end tabular-nums text-red-600">
<div
className={cn(
"flex items-center justify-end tabular-nums",
getChangeToneClass(t.change, "text-foreground dark:text-brand-50"),
)}
>
{fmt(t.price)}
</div>
<div