스킬 정리 및 리팩토링
This commit is contained in:
@@ -33,98 +33,18 @@ import {
|
||||
toRealtimeTickBar,
|
||||
upsertRealtimeBar,
|
||||
} from "./chart-utils";
|
||||
|
||||
const UP_COLOR = "#ef4444";
|
||||
const MINUTE_SYNC_INTERVAL_MS = 30000;
|
||||
const REALTIME_STALE_THRESHOLD_MS = 12000;
|
||||
const CHART_MIN_HEIGHT = 220;
|
||||
|
||||
interface ChartPalette {
|
||||
backgroundColor: string;
|
||||
downColor: string;
|
||||
volumeDownColor: string;
|
||||
textColor: string;
|
||||
borderColor: string;
|
||||
gridColor: string;
|
||||
crosshairColor: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CHART_PALETTE: ChartPalette = {
|
||||
backgroundColor: "#ffffff",
|
||||
downColor: "#2563eb",
|
||||
volumeDownColor: "rgba(37, 99, 235, 0.45)",
|
||||
textColor: "#6d28d9",
|
||||
borderColor: "#e9d5ff",
|
||||
gridColor: "#f3e8ff",
|
||||
crosshairColor: "#c084fc",
|
||||
};
|
||||
|
||||
function readCssVar(name: string, fallback: string) {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
const value = window
|
||||
.getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(name)
|
||||
.trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
function getChartPaletteFromCssVars(themeMode: "light" | "dark"): ChartPalette {
|
||||
const isDark = themeMode === "dark";
|
||||
const backgroundVar = isDark
|
||||
? "--brand-chart-background-dark"
|
||||
: "--brand-chart-background-light";
|
||||
const textVar = isDark
|
||||
? "--brand-chart-text-dark"
|
||||
: "--brand-chart-text-light";
|
||||
const borderVar = isDark
|
||||
? "--brand-chart-border-dark"
|
||||
: "--brand-chart-border-light";
|
||||
const gridVar = isDark
|
||||
? "--brand-chart-grid-dark"
|
||||
: "--brand-chart-grid-light";
|
||||
const crosshairVar = isDark
|
||||
? "--brand-chart-crosshair-dark"
|
||||
: "--brand-chart-crosshair-light";
|
||||
|
||||
return {
|
||||
backgroundColor: readCssVar(
|
||||
backgroundVar,
|
||||
DEFAULT_CHART_PALETTE.backgroundColor,
|
||||
),
|
||||
downColor: readCssVar(
|
||||
"--brand-chart-down",
|
||||
DEFAULT_CHART_PALETTE.downColor,
|
||||
),
|
||||
volumeDownColor: readCssVar(
|
||||
"--brand-chart-volume-down",
|
||||
DEFAULT_CHART_PALETTE.volumeDownColor,
|
||||
),
|
||||
textColor: readCssVar(textVar, DEFAULT_CHART_PALETTE.textColor),
|
||||
borderColor: readCssVar(borderVar, DEFAULT_CHART_PALETTE.borderColor),
|
||||
gridColor: readCssVar(gridVar, DEFAULT_CHART_PALETTE.gridColor),
|
||||
crosshairColor: readCssVar(
|
||||
crosshairVar,
|
||||
DEFAULT_CHART_PALETTE.crosshairColor,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const MINUTE_TIMEFRAMES: Array<{
|
||||
value: DashboardChartTimeframe;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "1m", label: "1분" },
|
||||
{ value: "30m", label: "30분" },
|
||||
{ value: "1h", label: "1시간" },
|
||||
];
|
||||
|
||||
const PERIOD_TIMEFRAMES: Array<{
|
||||
value: DashboardChartTimeframe;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "1d", label: "일" },
|
||||
{ value: "1w", label: "주" },
|
||||
];
|
||||
import {
|
||||
areBarsEqual,
|
||||
type ChartPalette,
|
||||
CHART_MIN_HEIGHT,
|
||||
DEFAULT_CHART_PALETTE,
|
||||
getChartPaletteFromCssVars,
|
||||
MINUTE_SYNC_INTERVAL_MS,
|
||||
MINUTE_TIMEFRAMES,
|
||||
PERIOD_TIMEFRAMES,
|
||||
REALTIME_STALE_THRESHOLD_MS,
|
||||
UP_COLOR,
|
||||
} from "./stock-line-chart-meta";
|
||||
|
||||
interface StockLineChartProps {
|
||||
symbol?: string;
|
||||
@@ -161,6 +81,7 @@ export function StockLineChart({
|
||||
const lastRealtimeAppliedAtRef = useRef(0);
|
||||
const chartPaletteRef = useRef<ChartPalette>(DEFAULT_CHART_PALETTE);
|
||||
const renderableBarsRef = useRef<ChartBar[]>([]);
|
||||
const initialThemeModeRef = useRef<"light" | "dark">("light");
|
||||
|
||||
const activeThemeMode: "light" | "dark" =
|
||||
resolvedTheme === "dark"
|
||||
@@ -172,6 +93,10 @@ export function StockLineChart({
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
useEffect(() => {
|
||||
initialThemeModeRef.current = activeThemeMode;
|
||||
}, [activeThemeMode]);
|
||||
|
||||
// 복수 이벤트에서 중복 로드를 막기 위한 ref 상태
|
||||
const loadingMoreRef = useRef(false);
|
||||
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
|
||||
@@ -244,7 +169,9 @@ export function StockLineChart({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to render chart series data:", error);
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.error("Failed to render chart series data:", error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -296,7 +223,7 @@ export function StockLineChart({
|
||||
if (!container || chartRef.current) return;
|
||||
|
||||
// 브랜드 색상은 globals.css의 CSS 변수에서 읽어 차트(canvas)에도 동일하게 적용합니다.
|
||||
const palette = getChartPaletteFromCssVars(activeThemeMode);
|
||||
const palette = getChartPaletteFromCssVars(initialThemeModeRef.current);
|
||||
chartPaletteRef.current = palette;
|
||||
|
||||
const chart = createChart(container, {
|
||||
@@ -411,7 +338,7 @@ export function StockLineChart({
|
||||
volumeSeriesRef.current = null;
|
||||
setIsChartReady(false);
|
||||
};
|
||||
}, [activeThemeMode]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const chart = chartRef.current;
|
||||
@@ -460,6 +387,7 @@ export function StockLineChart({
|
||||
|
||||
initialLoadCompleteRef.current = false;
|
||||
let disposed = false;
|
||||
let initialLoadTimer: number | null = null;
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -508,7 +436,7 @@ export function StockLineChart({
|
||||
setBars(mergedBars);
|
||||
setNextCursor(resolvedNextCursor);
|
||||
|
||||
window.setTimeout(() => {
|
||||
initialLoadTimer = window.setTimeout(() => {
|
||||
if (!disposed) initialLoadCompleteRef.current = true;
|
||||
}, 350);
|
||||
} catch (error) {
|
||||
@@ -531,6 +459,9 @@ export function StockLineChart({
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (initialLoadTimer !== null) {
|
||||
window.clearTimeout(initialLoadTimer);
|
||||
}
|
||||
};
|
||||
}, [credentials, symbol, timeframe]);
|
||||
|
||||
@@ -550,7 +481,7 @@ export function StockLineChart({
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!latestTick) return;
|
||||
if (bars.length === 0) return;
|
||||
if (renderableBarsRef.current.length === 0) return;
|
||||
|
||||
const dedupeKey = `${timeframe}:${latestTick.tickTime}:${latestTick.price}:${latestTick.tradeVolume}`;
|
||||
if (lastRealtimeKeyRef.current === dedupeKey) return;
|
||||
@@ -561,7 +492,7 @@ export function StockLineChart({
|
||||
lastRealtimeKeyRef.current = dedupeKey;
|
||||
lastRealtimeAppliedAtRef.current = Date.now();
|
||||
setBars((prev) => upsertRealtimeBar(prev, realtimeBar));
|
||||
}, [bars.length, latestTick, timeframe]);
|
||||
}, [latestTick, timeframe]);
|
||||
|
||||
/**
|
||||
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
|
||||
@@ -715,25 +646,3 @@ export function StockLineChart({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function areBarsEqual(left: ChartBar[], right: ChartBar[]) {
|
||||
if (left.length !== right.length) return false;
|
||||
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const lhs = left[index];
|
||||
const rhs = right[index];
|
||||
if (!lhs || !rhs) return false;
|
||||
if (
|
||||
lhs.time !== rhs.time ||
|
||||
lhs.open !== rhs.open ||
|
||||
lhs.high !== rhs.high ||
|
||||
lhs.low !== rhs.low ||
|
||||
lhs.close !== rhs.close ||
|
||||
lhs.volume !== rhs.volume
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
126
features/trade/components/chart/stock-line-chart-meta.ts
Normal file
126
features/trade/components/chart/stock-line-chart-meta.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { DashboardChartTimeframe } from "@/features/trade/types/trade.types";
|
||||
import type { ChartBar } from "./chart-utils";
|
||||
|
||||
export const UP_COLOR = "#ef4444";
|
||||
export const MINUTE_SYNC_INTERVAL_MS = 30000;
|
||||
export const REALTIME_STALE_THRESHOLD_MS = 12000;
|
||||
export const CHART_MIN_HEIGHT = 220;
|
||||
|
||||
export interface ChartPalette {
|
||||
backgroundColor: string;
|
||||
downColor: string;
|
||||
volumeDownColor: string;
|
||||
textColor: string;
|
||||
borderColor: string;
|
||||
gridColor: string;
|
||||
crosshairColor: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_CHART_PALETTE: ChartPalette = {
|
||||
backgroundColor: "#ffffff",
|
||||
downColor: "#2563eb",
|
||||
volumeDownColor: "rgba(37, 99, 235, 0.45)",
|
||||
textColor: "#6d28d9",
|
||||
borderColor: "#e9d5ff",
|
||||
gridColor: "#f3e8ff",
|
||||
crosshairColor: "#c084fc",
|
||||
};
|
||||
|
||||
export const MINUTE_TIMEFRAMES: Array<{
|
||||
value: DashboardChartTimeframe;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "1m", label: "1분" },
|
||||
{ value: "30m", label: "30분" },
|
||||
{ value: "1h", label: "1시간" },
|
||||
];
|
||||
|
||||
export const PERIOD_TIMEFRAMES: Array<{
|
||||
value: DashboardChartTimeframe;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "1d", label: "일" },
|
||||
{ value: "1w", label: "주" },
|
||||
];
|
||||
|
||||
/**
|
||||
* @description 브랜드 CSS 변수에서 차트 팔레트를 읽어옵니다.
|
||||
* @see features/trade/components/chart/StockLineChart.tsx 차트 생성/테마 반영
|
||||
*/
|
||||
export function getChartPaletteFromCssVars(
|
||||
themeMode: "light" | "dark",
|
||||
): ChartPalette {
|
||||
const isDark = themeMode === "dark";
|
||||
const backgroundVar = isDark
|
||||
? "--brand-chart-background-dark"
|
||||
: "--brand-chart-background-light";
|
||||
const textVar = isDark
|
||||
? "--brand-chart-text-dark"
|
||||
: "--brand-chart-text-light";
|
||||
const borderVar = isDark
|
||||
? "--brand-chart-border-dark"
|
||||
: "--brand-chart-border-light";
|
||||
const gridVar = isDark
|
||||
? "--brand-chart-grid-dark"
|
||||
: "--brand-chart-grid-light";
|
||||
const crosshairVar = isDark
|
||||
? "--brand-chart-crosshair-dark"
|
||||
: "--brand-chart-crosshair-light";
|
||||
|
||||
return {
|
||||
backgroundColor: readCssVar(
|
||||
backgroundVar,
|
||||
DEFAULT_CHART_PALETTE.backgroundColor,
|
||||
),
|
||||
downColor: readCssVar(
|
||||
"--brand-chart-down",
|
||||
DEFAULT_CHART_PALETTE.downColor,
|
||||
),
|
||||
volumeDownColor: readCssVar(
|
||||
"--brand-chart-volume-down",
|
||||
DEFAULT_CHART_PALETTE.volumeDownColor,
|
||||
),
|
||||
textColor: readCssVar(textVar, DEFAULT_CHART_PALETTE.textColor),
|
||||
borderColor: readCssVar(borderVar, DEFAULT_CHART_PALETTE.borderColor),
|
||||
gridColor: readCssVar(gridVar, DEFAULT_CHART_PALETTE.gridColor),
|
||||
crosshairColor: readCssVar(
|
||||
crosshairVar,
|
||||
DEFAULT_CHART_PALETTE.crosshairColor,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 차트 데이터 배열이 동일한지 비교합니다.
|
||||
* @see features/trade/components/chart/StockLineChart.tsx 분봉 동기화 시 불필요한 상태 업데이트 방지
|
||||
*/
|
||||
export function areBarsEqual(left: ChartBar[], right: ChartBar[]) {
|
||||
if (left.length !== right.length) return false;
|
||||
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const lhs = left[index];
|
||||
const rhs = right[index];
|
||||
if (!lhs || !rhs) return false;
|
||||
if (
|
||||
lhs.time !== rhs.time ||
|
||||
lhs.open !== rhs.open ||
|
||||
lhs.high !== rhs.high ||
|
||||
lhs.low !== rhs.low ||
|
||||
lhs.close !== rhs.close ||
|
||||
lhs.volume !== rhs.volume
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function readCssVar(name: string, fallback: string) {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
const value = window
|
||||
.getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(name)
|
||||
.trim();
|
||||
return value || fallback;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
DashboardOrderSide,
|
||||
DashboardStockItem,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import { parseKisAccountParts } from "@/lib/kis/account";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OrderFormProps {
|
||||
@@ -60,6 +61,14 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accountParts = parseKisAccountParts(verifiedCredentials.accountNo);
|
||||
if (!accountParts) {
|
||||
alert(
|
||||
"계좌번호 형식이 올바르지 않습니다. 설정에서 8-2 형식(예: 12345678-01)으로 다시 확인해 주세요.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await placeOrder(
|
||||
{
|
||||
symbol: stock.symbol,
|
||||
@@ -67,8 +76,8 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
orderType: "limit",
|
||||
price: priceNum,
|
||||
quantity: qtyNum,
|
||||
accountNo: verifiedCredentials.accountNo,
|
||||
accountProductCode: "01",
|
||||
accountNo: `${accountParts.accountNo}-${accountParts.accountProductCode}`,
|
||||
accountProductCode: accountParts.accountProductCode,
|
||||
},
|
||||
verifiedCredentials,
|
||||
);
|
||||
@@ -84,8 +93,17 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
parseInt(quantity.replace(/,/g, "") || "0", 10);
|
||||
|
||||
const setPercent = (pct: string) => {
|
||||
// TODO: 계좌 잔고 연동 시 퍼센트 자동 계산으로 교체
|
||||
console.log("Percent clicked:", pct);
|
||||
const ratio = Number.parseInt(pct.replace("%", ""), 10) / 100;
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) return;
|
||||
|
||||
// UI 흐름: 비율 버튼 클릭 -> 보유수량 기준 계산(매도 탭) -> 주문수량 입력값 반영
|
||||
if (activeTab === "sell" && matchedHolding?.quantity) {
|
||||
const calculatedQuantity = Math.max(
|
||||
1,
|
||||
Math.floor(matchedHolding.quantity * ratio),
|
||||
);
|
||||
setQuantity(String(calculatedQuantity));
|
||||
}
|
||||
};
|
||||
|
||||
const isMarketDataAvailable = Boolean(stock);
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
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,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnimatedQuantity } from "./AnimatedQuantity";
|
||||
|
||||
// ─── 타입 ───────────────────────────────────────────────
|
||||
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";
|
||||
|
||||
interface OrderBookProps {
|
||||
symbol?: string;
|
||||
@@ -20,228 +31,10 @@ interface OrderBookProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface BookRow {
|
||||
price: number;
|
||||
size: number;
|
||||
changeValue: number | null;
|
||||
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),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ─── 유틸리티 함수 ──────────────────────────────────────
|
||||
|
||||
/** 천단위 구분 포맷 */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 "--:--:--";
|
||||
return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
// ─── 메인 컴포넌트 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다.
|
||||
* @description 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다.
|
||||
* @see features/trade/components/orderbook/orderbook-utils.ts 호가 계산/포맷 유틸
|
||||
* @see features/trade/components/orderbook/orderbook-sections.tsx 호가/체결/요약 UI 섹션
|
||||
*/
|
||||
export function OrderBook({
|
||||
symbol,
|
||||
@@ -256,21 +49,23 @@ export function OrderBook({
|
||||
() => buildFallbackLevelsFromTick(latestTick),
|
||||
[latestTick],
|
||||
);
|
||||
const hasRealtimeLevelData = useMemo(
|
||||
() => hasOrderBookLevelData(realtimeLevels),
|
||||
[realtimeLevels],
|
||||
);
|
||||
const levels = useMemo(() => {
|
||||
if (hasOrderBookLevelData(realtimeLevels)) return realtimeLevels;
|
||||
if (hasRealtimeLevelData) return realtimeLevels;
|
||||
return fallbackLevelsFromTick;
|
||||
}, [fallbackLevelsFromTick, realtimeLevels]);
|
||||
const isTickFallbackActive =
|
||||
!hasOrderBookLevelData(realtimeLevels) && fallbackLevelsFromTick.length > 0;
|
||||
}, [fallbackLevelsFromTick, hasRealtimeLevelData, realtimeLevels]);
|
||||
|
||||
const isTickFallbackActive =
|
||||
!hasRealtimeLevelData && fallbackLevelsFromTick.length > 0;
|
||||
|
||||
// 체결가: tick에서 우선, 없으면 0
|
||||
const latestPrice =
|
||||
latestTick?.price && latestTick.price > 0 ? latestTick.price : 0;
|
||||
|
||||
// 등락률 기준가
|
||||
const basePrice = resolveReferencePrice({ referencePrice, latestTick });
|
||||
|
||||
// 매도호가 (역순: 10호가 → 1호가)
|
||||
const askRows: BookRow[] = useMemo(
|
||||
() =>
|
||||
buildBookRows({
|
||||
@@ -282,7 +77,6 @@ export function OrderBook({
|
||||
[levels, basePrice, latestPrice],
|
||||
);
|
||||
|
||||
// 매수호가 (1호가 → 10호가)
|
||||
const bidRows: BookRow[] = useMemo(
|
||||
() =>
|
||||
buildBookRows({
|
||||
@@ -294,31 +88,42 @@ export function OrderBook({
|
||||
[levels, basePrice, latestPrice],
|
||||
);
|
||||
|
||||
const askMax = Math.max(1, ...askRows.map((r) => r.size));
|
||||
const bidMax = Math.max(1, ...bidRows.map((r) => r.size));
|
||||
const askMax = useMemo(() => Math.max(1, ...askRows.map((r) => r.size)), [askRows]);
|
||||
const bidMax = useMemo(() => Math.max(1, ...bidRows.map((r) => r.size)), [bidRows]);
|
||||
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;
|
||||
const bestBid = levels.find((l) => l.bidPrice > 0)?.bidPrice ?? 0;
|
||||
const spread = bestAsk > 0 && bestBid > 0 ? bestAsk - bestBid : 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
|
||||
: 0;
|
||||
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;
|
||||
|
||||
// 체결가 행 중앙 스크롤
|
||||
return {
|
||||
bestAsk: resolvedBestAsk,
|
||||
spread: resolvedSpread,
|
||||
totalAsk: resolvedTotalAsk,
|
||||
totalBid: resolvedTotalBid,
|
||||
imbalance: resolvedImbalance,
|
||||
};
|
||||
}, [latestTick?.totalAskSize, latestTick?.totalBidSize, levels, orderBook]);
|
||||
|
||||
// ─── 빈/로딩 상태 ───
|
||||
if (!symbol) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
@@ -340,7 +145,7 @@ export function OrderBook({
|
||||
return (
|
||||
<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">
|
||||
{/* 탭 헤더 */}
|
||||
{/* ========== ORDERBOOK TAB HEADER ========== */}
|
||||
<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">
|
||||
@@ -355,10 +160,9 @@ export function OrderBook({
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* ── 일반호가 탭 ── */}
|
||||
{/* ========== ORDERBOOK NORMAL TAB ========== */}
|
||||
<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)_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">
|
||||
@@ -368,7 +172,6 @@ export function OrderBook({
|
||||
)}
|
||||
<BookHeader />
|
||||
<div className="xl:hidden">
|
||||
{/* 모바일: 양방향 호가가 항상 보이도록 6호가씩 고정 노출 */}
|
||||
<BookSideRows rows={mobileAskRows} side="ask" maxSize={askMax} />
|
||||
<CurrentPriceBar
|
||||
latestPrice={latestPrice}
|
||||
@@ -380,7 +183,6 @@ export function OrderBook({
|
||||
<BookSideRows rows={mobileBidRows} side="bid" maxSize={bidMax} />
|
||||
</div>
|
||||
<ScrollArea className="hidden min-h-0 flex-1 xl:block">
|
||||
{/* 데스크톱: 전체 호가 스크롤 */}
|
||||
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
||||
<CurrentPriceBar
|
||||
latestPrice={latestPrice}
|
||||
@@ -393,14 +195,12 @@ export function OrderBook({
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 체결량 영역 */}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 실시간 정보 영역 */}
|
||||
<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
|
||||
@@ -416,7 +216,7 @@ export function OrderBook({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── 누적호가 탭 ── */}
|
||||
{/* ========== ORDERBOOK CUMULATIVE TAB ========== */}
|
||||
<TabsContent value="cumulative" className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full border-t dark:border-brand-800/45">
|
||||
<div className="p-3">
|
||||
@@ -430,7 +230,7 @@ export function OrderBook({
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── 호가주문 탭 ── */}
|
||||
{/* ========== ORDERBOOK ORDER TAB ========== */}
|
||||
<TabsContent value="order" className="min-h-0 flex-1">
|
||||
<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">
|
||||
호가주문 탭은 주문 입력 패널과 연동해 확장할 수 있습니다.
|
||||
@@ -440,454 +240,3 @@ export function OrderBook({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 하위 컴포넌트 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 호가창 중앙의 현재가/등락률/총잔량 바를 렌더링합니다.
|
||||
* @summary UI 흐름: OrderBook -> CurrentPriceBar -> 매도/매수 구간 사이 현재 체결 상태를 강조 표시
|
||||
* @see features/trade/components/orderbook/OrderBook.tsx 일반호가 탭의 모바일/데스크톱 공통 현재가 행
|
||||
*/
|
||||
function CurrentPriceBar({
|
||||
latestPrice,
|
||||
basePrice,
|
||||
bestAsk,
|
||||
totalAsk,
|
||||
totalBid,
|
||||
}: {
|
||||
latestPrice: number;
|
||||
basePrice: number;
|
||||
bestAsk: number;
|
||||
totalAsk: number;
|
||||
totalBid: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid h-10 grid-cols-3 items-center border-y-2 border-amber-400 bg-linear-to-r from-blue-50/60 via-amber-50/90 to-red-50/60 shadow-[0_0_10px_rgba(251,191,36,0.25)] dark:from-blue-950/30 dark:via-amber-900/30 dark:to-red-950/30 xl:h-10">
|
||||
<div className="px-2 text-right text-[10px] font-semibold text-blue-600 dark:text-blue-400">
|
||||
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
||||
</div>
|
||||
<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 ? fmt(bestAsk) : "-"}
|
||||
</span>
|
||||
{latestPrice > 0 && basePrice > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-semibold leading-none",
|
||||
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-semibold text-red-600 dark:text-red-400">
|
||||
{totalBid > 0 ? fmt(totalBid) : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 호가 표 헤더 */
|
||||
function BookHeader() {
|
||||
return (
|
||||
<div className="grid h-8 grid-cols-3 border-b bg-linear-to-r from-blue-50/40 via-muted/20 to-red-50/40 text-[11px] font-semibold text-muted-foreground dark:border-brand-800/50 dark:from-blue-950/30 dark:via-brand-900/40 dark:to-red-950/30 dark:text-brand-100/80">
|
||||
<div className="flex items-center justify-end px-2 text-blue-600/80 dark:text-blue-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-red-600/80 dark:text-red-400/80">
|
||||
매수잔량
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 매도 또는 매수 호가 행 목록 */
|
||||
function BookSideRows({
|
||||
rows,
|
||||
side,
|
||||
maxSize,
|
||||
}: {
|
||||
rows: BookRow[];
|
||||
side: "ask" | "bid";
|
||||
maxSize: number;
|
||||
}) {
|
||||
const isAsk = side === "ask";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
isAsk
|
||||
? "bg-linear-to-r from-blue-50/40 via-blue-50/10 to-transparent dark:from-blue-950/35 dark:via-blue-950/10 dark:to-transparent"
|
||||
: "bg-linear-to-r from-transparent via-red-50/10 to-red-50/45 dark:from-transparent dark:via-red-950/10 dark:to-red-950/35",
|
||||
)}
|
||||
>
|
||||
{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(
|
||||
"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/60 dark:bg-amber-800/35",
|
||||
)}
|
||||
>
|
||||
{/* 매도잔량 (좌측) */}
|
||||
<div className="relative flex items-center justify-end overflow-hidden px-2">
|
||||
{isAsk && (
|
||||
<>
|
||||
<DepthBar ratio={ratio} side="ask" />
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 호가 (중앙) */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
|
||||
row.isHighlighted &&
|
||||
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[12px] xl:text-[13px]",
|
||||
isAsk ? "text-blue-600 dark:text-blue-400" : "text-red-600",
|
||||
)}
|
||||
>
|
||||
{row.price > 0 ? fmt(row.price) : "-"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"w-[50px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
|
||||
getChangeToneClass(row.changeValue),
|
||||
)}
|
||||
>
|
||||
{row.changeValue === null
|
||||
? "-"
|
||||
: fmtSignedChange(row.changeValue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 매수잔량 (우측) */}
|
||||
<div className="relative flex items-center justify-start overflow-hidden px-1">
|
||||
{!isAsk && (
|
||||
<>
|
||||
<DepthBar ratio={ratio} side="bid" />
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 우측 요약 패널 */
|
||||
function SummaryPanel({
|
||||
orderBook,
|
||||
latestTick,
|
||||
spread,
|
||||
imbalance,
|
||||
totalAsk,
|
||||
totalBid,
|
||||
}: {
|
||||
orderBook: DashboardStockOrderBookResponse | null;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
spread: number;
|
||||
imbalance: number;
|
||||
totalAsk: number;
|
||||
totalBid: number;
|
||||
}) {
|
||||
const displayTradeVolume =
|
||||
latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0
|
||||
? (orderBook?.anticipatedVolume ?? 0)
|
||||
: (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0);
|
||||
const summaryItems: SummaryMetric[] = [
|
||||
{
|
||||
label: "실시간",
|
||||
value: orderBook || latestTick ? "연결됨" : "끊김",
|
||||
tone: orderBook || latestTick ? "ask" : 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="h-full min-w-0 bg-muted/10 p-2 text-xs dark:bg-brand-900/30">
|
||||
<div className="grid grid-cols-2 gap-1 xl:h-full xl:grid-cols-1 xl:grid-rows-12">
|
||||
{summaryItems.map((item) => (
|
||||
<SummaryMetricCell
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
value={item.value}
|
||||
tone={item.tone}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "ask" | "bid";
|
||||
}) {
|
||||
return (
|
||||
<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 text-xs font-semibold tabular-nums",
|
||||
tone === "ask" && "text-blue-600 dark:text-blue-400",
|
||||
tone === "bid" && "text-red-600",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 잔량 깊이 바 */
|
||||
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
||||
if (ratio <= 0) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0.5 z-0 rounded-sm transition-[width] duration-150",
|
||||
side === "ask"
|
||||
? "right-0.5 bg-blue-300/55 dark:bg-blue-700/50"
|
||||
: "left-0.5 bg-red-300/60 dark:bg-red-600/45",
|
||||
)}
|
||||
style={{ width: `${ratio}%` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 체결 목록 (Trade Tape) */
|
||||
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-[96px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70 xl:min-h-[140px]">
|
||||
체결 데이터가 아직 없습니다.
|
||||
</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 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>
|
||||
{shouldUseScrollableList ? (
|
||||
<ScrollArea className="min-h-0 flex-1">{tapeRows}</ScrollArea>
|
||||
) : (
|
||||
tapeRows
|
||||
)}
|
||||
</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}
|
||||
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"
|
||||
>
|
||||
<span className="tabular-nums text-blue-600 dark:text-blue-400">{fmt(r.askAcc)}</span>
|
||||
<span className="text-center font-medium tabular-nums">
|
||||
{fmt(r.price)}
|
||||
</span>
|
||||
<span className="text-right tabular-nums text-red-600 dark:text-red-400">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
473
features/trade/components/orderbook/orderbook-sections.tsx
Normal file
473
features/trade/components/orderbook/orderbook-sections.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
import { useMemo } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type {
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockOrderBookResponse,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnimatedQuantity } from "./AnimatedQuantity";
|
||||
import type { BookRow } from "./orderbook-utils";
|
||||
import {
|
||||
fmt,
|
||||
fmtPct,
|
||||
fmtSignedChange,
|
||||
fmtTime,
|
||||
getChangeToneClass,
|
||||
pctChange,
|
||||
resolveTickExecutionSide,
|
||||
} from "./orderbook-utils";
|
||||
|
||||
/**
|
||||
* @description 호가창 중앙의 현재가/등락률/총잔량 바를 렌더링합니다.
|
||||
* @summary UI 흐름: OrderBook -> CurrentPriceBar -> 매도/매수 구간 사이 현재 체결 상태를 강조 표시
|
||||
* @see features/trade/components/orderbook/OrderBook.tsx 일반호가 탭의 모바일/데스크톱 공통 현재가 행
|
||||
*/
|
||||
export function CurrentPriceBar({
|
||||
latestPrice,
|
||||
basePrice,
|
||||
bestAsk,
|
||||
totalAsk,
|
||||
totalBid,
|
||||
}: {
|
||||
latestPrice: number;
|
||||
basePrice: number;
|
||||
bestAsk: number;
|
||||
totalAsk: number;
|
||||
totalBid: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid h-10 grid-cols-3 items-center border-y-2 border-amber-400 bg-linear-to-r from-blue-50/60 via-amber-50/90 to-red-50/60 shadow-[0_0_10px_rgba(251,191,36,0.25)] dark:from-blue-950/30 dark:via-amber-900/30 dark:to-red-950/30 xl:h-10">
|
||||
<div className="px-2 text-right text-[10px] font-semibold text-blue-600 dark:text-blue-400">
|
||||
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
||||
</div>
|
||||
<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 ? fmt(bestAsk) : "-"}
|
||||
</span>
|
||||
{latestPrice > 0 && basePrice > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-semibold leading-none",
|
||||
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-semibold text-red-600 dark:text-red-400">
|
||||
{totalBid > 0 ? fmt(totalBid) : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 호가 표 헤더 */
|
||||
export function BookHeader() {
|
||||
return (
|
||||
<div className="grid h-8 grid-cols-3 border-b bg-linear-to-r from-blue-50/40 via-muted/20 to-red-50/40 text-[11px] font-semibold text-muted-foreground dark:border-brand-800/50 dark:from-blue-950/30 dark:via-brand-900/40 dark:to-red-950/30 dark:text-brand-100/80">
|
||||
<div className="flex items-center justify-end px-2 text-blue-600/80 dark:text-blue-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-red-600/80 dark:text-red-400/80">
|
||||
매수잔량
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 매도 또는 매수 호가 행 목록 */
|
||||
export function BookSideRows({
|
||||
rows,
|
||||
side,
|
||||
maxSize,
|
||||
}: {
|
||||
rows: BookRow[];
|
||||
side: "ask" | "bid";
|
||||
maxSize: number;
|
||||
}) {
|
||||
const isAsk = side === "ask";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
isAsk
|
||||
? "bg-linear-to-r from-blue-50/40 via-blue-50/10 to-transparent dark:from-blue-950/35 dark:via-blue-950/10 dark:to-transparent"
|
||||
: "bg-linear-to-r from-transparent via-red-50/10 to-red-50/45 dark:from-transparent dark:via-red-950/10 dark:to-red-950/35",
|
||||
)}
|
||||
>
|
||||
{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(
|
||||
"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/60 dark:bg-amber-800/35",
|
||||
)}
|
||||
>
|
||||
<div className="relative flex items-center justify-end overflow-hidden px-2">
|
||||
{isAsk && (
|
||||
<>
|
||||
<DepthBar ratio={ratio} side="ask" />
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
|
||||
row.isHighlighted &&
|
||||
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[12px] xl:text-[13px]",
|
||||
isAsk ? "text-blue-600 dark:text-blue-400" : "text-red-600",
|
||||
)}
|
||||
>
|
||||
{row.price > 0 ? fmt(row.price) : "-"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"w-[50px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
|
||||
getChangeToneClass(row.changeValue),
|
||||
)}
|
||||
>
|
||||
{row.changeValue === null
|
||||
? "-"
|
||||
: fmtSignedChange(row.changeValue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center justify-start overflow-hidden px-1">
|
||||
{!isAsk && (
|
||||
<>
|
||||
<DepthBar ratio={ratio} side="bid" />
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 우측 요약 패널 */
|
||||
export function SummaryPanel({
|
||||
orderBook,
|
||||
latestTick,
|
||||
spread,
|
||||
imbalance,
|
||||
totalAsk,
|
||||
totalBid,
|
||||
}: {
|
||||
orderBook: DashboardStockOrderBookResponse | null;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
spread: number;
|
||||
imbalance: number;
|
||||
totalAsk: number;
|
||||
totalBid: number;
|
||||
}) {
|
||||
const displayTradeVolume =
|
||||
latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0
|
||||
? (orderBook?.anticipatedVolume ?? 0)
|
||||
: (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0);
|
||||
const summaryItems: SummaryMetric[] = [
|
||||
{
|
||||
label: "실시간",
|
||||
value: orderBook || latestTick ? "연결됨" : "끊김",
|
||||
tone: orderBook || latestTick ? "ask" : 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="h-full min-w-0 bg-muted/10 p-2 text-xs dark:bg-brand-900/30">
|
||||
<div className="grid grid-cols-2 gap-1 xl:h-full xl:grid-cols-1 xl:grid-rows-12">
|
||||
{summaryItems.map((item) => (
|
||||
<SummaryMetricCell
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
value={item.value}
|
||||
tone={item.tone}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 체결 목록 (Trade Tape) */
|
||||
export 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-[96px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70 xl:min-h-[140px]">
|
||||
체결 데이터가 아직 없습니다.
|
||||
</div>
|
||||
)}
|
||||
{visibleTicks.map((t, i) => {
|
||||
const olderTick = visibleTicks[i + 1];
|
||||
const executionSide = resolveTickExecutionSide(t, olderTick);
|
||||
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 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>
|
||||
{shouldUseScrollableList ? (
|
||||
<ScrollArea className="min-h-0 flex-1">{tapeRows}</ScrollArea>
|
||||
) : (
|
||||
tapeRows
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 누적호가 행 */
|
||||
export 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 += 1) {
|
||||
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}
|
||||
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"
|
||||
>
|
||||
<span className="tabular-nums text-blue-600 dark:text-blue-400">
|
||||
{fmt(r.askAcc)}
|
||||
</span>
|
||||
<span className="text-center font-medium tabular-nums">
|
||||
{fmt(r.price)}
|
||||
</span>
|
||||
<span className="text-right tabular-nums text-red-600 dark:text-red-400">
|
||||
{fmt(r.bidAcc)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 로딩 스켈레톤 */
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
interface SummaryMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "ask" | "bid";
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 요약 패널의 2열 메트릭 셀을 렌더링합니다.
|
||||
* @summary UI 흐름: SummaryPanel -> SummaryMetricCell -> 체결량 하단 정보 패널을 압축해 스크롤 없이 표시
|
||||
* @see features/trade/components/orderbook/orderbook-sections.tsx SummaryPanel summaryItems
|
||||
*/
|
||||
function SummaryMetricCell({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "ask" | "bid";
|
||||
}) {
|
||||
return (
|
||||
<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 text-xs font-semibold tabular-nums",
|
||||
tone === "ask" && "text-blue-600 dark:text-blue-400",
|
||||
tone === "bid" && "text-red-600",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 잔량 깊이 바 */
|
||||
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
||||
if (ratio <= 0) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0.5 z-0 rounded-sm transition-[width] duration-150",
|
||||
side === "ask"
|
||||
? "right-0.5 bg-blue-300/55 dark:bg-blue-700/50"
|
||||
: "left-0.5 bg-red-300/60 dark:bg-red-600/45",
|
||||
)}
|
||||
style={{ width: `${ratio}%` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
210
features/trade/components/orderbook/orderbook-utils.ts
Normal file
210
features/trade/components/orderbook/orderbook-utils.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type {
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockOrderBookResponse,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
|
||||
type OrderBookLevels = DashboardStockOrderBookResponse["levels"];
|
||||
|
||||
export interface BookRow {
|
||||
price: number;
|
||||
size: number;
|
||||
changeValue: number | null;
|
||||
isHighlighted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다.
|
||||
* @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다.
|
||||
*/
|
||||
export function hasOrderBookLevelData(levels: OrderBookLevels) {
|
||||
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호가/잔량/총잔량을 파싱합니다.
|
||||
*/
|
||||
export function buildFallbackLevelsFromTick(
|
||||
latestTick: DashboardRealtimeTradeTick | null,
|
||||
) {
|
||||
if (!latestTick) return [] as OrderBookLevels;
|
||||
if (latestTick.askPrice1 <= 0 && latestTick.bidPrice1 <= 0) {
|
||||
return [] as OrderBookLevels;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
askPrice: latestTick.askPrice1,
|
||||
bidPrice: latestTick.bidPrice1,
|
||||
askSize: Math.max(latestTick.askSize1, 0),
|
||||
bidSize: Math.max(latestTick.bidSize1, 0),
|
||||
},
|
||||
] satisfies OrderBookLevels;
|
||||
}
|
||||
|
||||
/** 천단위 구분 포맷 */
|
||||
export function fmt(v: number) {
|
||||
return Number.isFinite(v) ? v.toLocaleString("ko-KR") : "0";
|
||||
}
|
||||
|
||||
/** 부호 포함 퍼센트 */
|
||||
export function fmtPct(v: number) {
|
||||
return `${v > 0 ? "+" : ""}${v.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
/** 등락률 계산 */
|
||||
export function pctChange(price: number, base: number) {
|
||||
return base > 0 ? ((price - base) / base) * 100 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 증감 숫자를 부호 포함 문자열로 포맷합니다.
|
||||
* @see features/trade/components/orderbook/orderbook-sections.tsx BookSideRows
|
||||
*/
|
||||
export 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-sections.tsx BookSideRows
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** 체결 시각 포맷 */
|
||||
export function fmtTime(hms: string) {
|
||||
if (!hms || hms.length !== 6) return "--:--:--";
|
||||
return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 체결 틱 데이터에서 체결 주도 방향(매수/매도/중립)을 계산합니다.
|
||||
* @see features/trade/components/orderbook/orderbook-sections.tsx TradeTape 체결량 글자색 결정에 사용합니다.
|
||||
*/
|
||||
export function resolveTickExecutionSide(
|
||||
tick: DashboardRealtimeTradeTick,
|
||||
olderTick?: DashboardRealtimeTradeTick,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 호가 레벨을 화면 렌더링용 행 모델로 변환합니다.
|
||||
* @summary UI 흐름: OrderBook(levels/basePrice/latestPrice) -> buildBookRows -> BookSideRows -> 가격/증감 반영
|
||||
* @see features/trade/components/orderbook/OrderBook.tsx OrderBook askRows/bidRows 계산
|
||||
*/
|
||||
export function buildBookRows({
|
||||
levels,
|
||||
side,
|
||||
basePrice,
|
||||
latestPrice,
|
||||
}: {
|
||||
levels: OrderBookLevels;
|
||||
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 계산
|
||||
*/
|
||||
export function resolveReferencePrice({
|
||||
referencePrice,
|
||||
latestTick,
|
||||
}: {
|
||||
referencePrice?: number;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
}) {
|
||||
if ((referencePrice ?? 0) > 0) {
|
||||
return referencePrice!;
|
||||
}
|
||||
|
||||
if (latestTick?.price && Number.isFinite(latestTick.change)) {
|
||||
const derivedPrevClose = latestTick.price - latestTick.change;
|
||||
if (derivedPrevClose > 0) {
|
||||
return derivedPrevClose;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function resolvePriceChange(price: number, basePrice: number) {
|
||||
if (price <= 0 || basePrice <= 0) {
|
||||
return null;
|
||||
}
|
||||
return price - basePrice;
|
||||
}
|
||||
Reference in New Issue
Block a user