스킬 정리 및 리팩토링

This commit is contained in:
2026-02-26 09:05:17 +09:00
parent 4c52d6d82f
commit 406af7408a
71 changed files with 3776 additions and 3934 deletions

View File

@@ -66,6 +66,7 @@ export function SessionManager() {
for (const key of SESSION_RELATED_STORAGE_KEYS) {
window.localStorage.removeItem(key);
window.sessionStorage.removeItem(key);
}
}, []);

View File

@@ -1,4 +1,9 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import {
buildKisRequestHeaders,
resolveKisApiErrorMessage,
type KisApiErrorPayload,
} from "@/features/settings/apis/kis-api-utils";
import type {
DashboardActivityResponse,
DashboardBalanceResponse,
@@ -21,18 +26,16 @@ export async function fetchDashboardBalance(
): Promise<DashboardBalanceResponse> {
const response = await fetch("/api/kis/domestic/balance", {
method: "GET",
headers: buildKisRequestHeaders(credentials),
headers: buildKisRequestHeaders(credentials, { includeAccountNo: true }),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardBalanceResponse
| { error?: string };
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "잔고 조회 중 오류가 발생했습니다.",
);
throw new Error(resolveKisApiErrorMessage(payload, "잔고 조회 중 오류가 발생했습니다."));
}
return payload as DashboardBalanceResponse;
@@ -55,12 +58,10 @@ export async function fetchDashboardIndices(
const payload = (await response.json()) as
| DashboardIndicesResponse
| { error?: string };
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "지수 조회 중 오류가 발생했습니다.",
);
throw new Error(resolveKisApiErrorMessage(payload, "지수 조회 중 오류가 발생했습니다."));
}
return payload as DashboardIndicesResponse;
@@ -77,39 +78,17 @@ export async function fetchDashboardActivity(
): Promise<DashboardActivityResponse> {
const response = await fetch("/api/kis/domestic/activity", {
method: "GET",
headers: buildKisRequestHeaders(credentials),
headers: buildKisRequestHeaders(credentials, { includeAccountNo: true }),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardActivityResponse
| { error?: string };
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "활동 데이터 조회 중 오류가 발생했습니다.",
);
throw new Error(resolveKisApiErrorMessage(payload, "활동 데이터 조회 중 오류가 발생했습니다."));
}
return payload as DashboardActivityResponse;
}
/**
* 대시보드 API 공통 헤더를 구성합니다.
* @param credentials KIS 인증 정보
* @returns KIS 전달 헤더
* @see features/dashboard/apis/dashboard.api.ts fetchDashboardBalance/fetchDashboardIndices
*/
function buildKisRequestHeaders(credentials: KisRuntimeCredentials) {
const headers: Record<string, string> = {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
};
if (credentials.accountNo?.trim()) {
headers["x-kis-account-no"] = credentials.accountNo.trim();
}
return headers;
}

View File

@@ -1,29 +0,0 @@
"use client";
import Spline from "@splinetool/react-spline";
import { useState } from "react";
import { cn } from "@/lib/utils";
interface SplineSceneProps {
sceneUrl: string;
className?: string;
}
export function SplineScene({ sceneUrl, className }: SplineSceneProps) {
const [isLoading, setIsLoading] = useState(true);
return (
<div className={cn("relative h-full w-full", className)}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-zinc-100 dark:bg-zinc-900">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-zinc-200 border-t-brand-500 dark:border-zinc-800" />
</div>
)}
<Spline
scene={sceneUrl}
onLoad={() => setIsLoading(false)}
className="h-full w-full"
/>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { create } from "zustand";
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { buildKisRealtimeMessage } from "@/features/kis-realtime/utils/websocketUtils";
@@ -63,6 +64,21 @@ const RECONNECT_BASE_DELAY_MS = 1_000;
const RECONNECT_MAX_DELAY_MS = 30_000;
const RECONNECT_JITTER_MS = 300;
function isKisWsDebugEnabled() {
if (typeof window === "undefined") return false;
return window.localStorage.getItem("KIS_WS_DEBUG") === "1";
}
function wsDebugLog(...args: unknown[]) {
if (!isKisWsDebugEnabled()) return;
console.log(...args);
}
function wsDebugWarn(...args: unknown[]) {
if (!isKisWsDebugEnabled()) return;
console.warn(...args);
}
export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
isConnected: false,
error: null,
@@ -105,7 +121,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
// 소켓 생성
// socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지
const ws = new WebSocket(wsConnection.wsUrl);
console.log("[KisWebSocket] Connecting to:", wsConnection.wsUrl);
wsDebugLog("[KisWebSocket] Connecting to:", wsConnection.wsUrl);
socket = ws;
ws.onopen = () => {
@@ -116,7 +132,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
set({ isConnected: true, error: null });
reconnectAttempt = 0;
console.log("[KisWebSocket] Connected");
wsDebugLog("[KisWebSocket] Connected");
// 재연결 시 기존 구독 복구
const approvalKey = wsConnection.approvalKey;
@@ -147,7 +163,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
if (canAutoReconnect) {
reconnectAttempt += 1;
const delayMs = getReconnectDelayMs(reconnectAttempt);
console.warn(
wsDebugWarn(
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"}, wasClean=${event.wasClean}) -> reconnect in ${delayMs}ms (attempt ${reconnectAttempt}/${MAX_AUTO_RECONNECT_ATTEMPTS})`,
);
@@ -170,7 +186,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
}
reconnectAttempt = 0;
console.log(
wsDebugLog(
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"})`,
);
}
@@ -221,15 +237,15 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
// KIS 서버가 세션을 정리하는 데 최소 10~30초가 필요하므로
// 충분한 대기 후 재연결합니다.
if (control.msgCd === "OPSP8996") {
const now = Date.now();
if (now - lastAppKeyConflictAt > 5_000) {
lastAppKeyConflictAt = now;
console.warn(
"[KisWebSocket] ALREADY IN USE appkey - 현재 소켓을 닫고 30초 후 재연결합니다.",
);
// 현재 소켓을 즉시 닫아 서버 측 세션 정리 유도
if (socket === ws && ws.readyState === WebSocket.OPEN) {
ws.close(1000, "ALREADY IN USE - graceful close");
const now = Date.now();
if (now - lastAppKeyConflictAt > 5_000) {
lastAppKeyConflictAt = now;
wsDebugWarn(
"[KisWebSocket] ALREADY IN USE appkey - 현재 소켓을 닫고 30초 후 재연결합니다.",
);
// 현재 소켓을 즉시 닫아 서버 측 세션 정리 유도
if (socket === ws && ws.readyState === WebSocket.OPEN) {
ws.close(1000, "ALREADY IN USE - graceful close");
}
window.clearTimeout(reconnectRetryTimer);
reconnectRetryTimer = window.setTimeout(() => {
@@ -374,11 +390,11 @@ function sendSubscription(
try {
const msg = buildKisRealtimeMessage(appKey, symbol, trId, trType);
ws.send(JSON.stringify(msg));
console.debug(
wsDebugLog(
`[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`,
);
} catch (e) {
console.warn("[KisWebSocket] Send error", e);
wsDebugWarn("[KisWebSocket] Send error", e);
}
}
@@ -440,7 +456,10 @@ function buildControlErrorMessage(message: KisWsControlMessage) {
if (message.msgCd === "OPSP8996") {
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
}
const detail = [message.msg1, message.msgCd].filter(Boolean).join(" / ");
const detail = buildKisErrorDetail({
message: message.msg1,
msgCode: message.msgCd,
});
return detail
? `실시간 제어 메시지 오류: ${detail}`
: "실시간 제어 메시지 오류";

View File

@@ -6,8 +6,6 @@ import {
ChevronLeft,
Home,
Settings,
User,
Wallet,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -31,20 +29,6 @@ const MENU_ITEMS: MenuItem[] = [
badge: "LIVE",
showInBottomNav: true,
},
{
title: "자산현황",
href: "/assets",
icon: Wallet,
variant: "ghost",
showInBottomNav: true,
},
{
title: "프로필",
href: "/profile",
icon: User,
variant: "ghost",
showInBottomNav: false,
},
{
title: "설정",
href: "/settings",

View File

@@ -6,7 +6,7 @@
"use client";
import { User } from "@supabase/supabase-js";
import { LogOut, Settings, User as UserIcon } from "lucide-react";
import { LogOut, Settings } from "lucide-react";
import { useRouter } from "next/navigation";
import { signout } from "@/features/auth/actions";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -54,6 +54,7 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
for (const key of SESSION_RELATED_STORAGE_KEYS) {
window.localStorage.removeItem(key);
window.sessionStorage.removeItem(key);
}
};
@@ -97,11 +98,6 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push("/profile")}>
<UserIcon className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/settings")}>
<Settings className="mr-2 h-4 w-4" />
<span></span>

View File

@@ -0,0 +1,82 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
export interface KisApiErrorPayload {
ok?: boolean;
message?: string;
error?: string;
errorCode?: string;
}
interface BuildKisRequestHeadersOptions {
jsonContentType?: boolean;
includeAccountNo?: boolean;
includeSessionOverride?: boolean;
}
/**
* @description KIS API 응답에서 사용자 노출용 에러 메시지를 추출합니다.
* @see features/trade/apis/kis-stock.api.ts 종목/주문 API 실패 처리
* @see features/dashboard/apis/dashboard.api.ts 대시보드 API 실패 처리
*/
export function resolveKisApiErrorMessage(
payload: unknown,
fallbackMessage: string,
) {
if (!payload || typeof payload !== "object") {
return fallbackMessage;
}
const response = payload as KisApiErrorPayload;
return response.message || response.error || fallbackMessage;
}
/**
* @description KIS API 호출용 공통 헤더를 생성합니다.
* @see features/dashboard/apis/dashboard.api.ts 잔고/지수/활동 조회 공통 헤더
* @see features/trade/apis/kis-stock.api.ts 종목/호가/차트/주문 공통 헤더
*/
export function buildKisRequestHeaders(
credentials: KisRuntimeCredentials,
options?: BuildKisRequestHeadersOptions,
) {
const headers: Record<string, string> = {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
};
if (options?.jsonContentType) {
headers["content-type"] = "application/json";
}
if (options?.includeAccountNo && credentials.accountNo.trim()) {
headers["x-kis-account-no"] = credentials.accountNo.trim();
}
if (options?.includeSessionOverride) {
const sessionOverride = readSessionOverrideForDev();
if (sessionOverride) {
headers[DOMESTIC_KIS_SESSION_OVERRIDE_HEADER] = sessionOverride;
}
}
return headers;
}
function readSessionOverrideForDev() {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
);
return parseDomesticKisSession(raw);
} catch {
return null;
}
}

View File

@@ -1,4 +1,8 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import {
resolveKisApiErrorMessage,
type KisApiErrorPayload,
} from "@/features/settings/apis/kis-api-utils";
import type {
DashboardKisProfileValidateResponse,
DashboardKisRevokeResponse,
@@ -25,13 +29,13 @@ async function postKisAuthApi<T extends KisApiBaseResponse>(
cache: "no-store",
});
const payload = (await response.json()) as T;
const payload = (await response.json()) as T | KisApiErrorPayload;
if (!response.ok || !payload.ok) {
throw new Error(payload.message || fallbackErrorMessage);
throw new Error(resolveKisApiErrorMessage(payload, fallbackErrorMessage));
}
return payload;
return payload as T;
}
/**

View File

@@ -240,11 +240,14 @@ export const useKisRuntimeStore = create<
}),
{
name: "autotrade-kis-runtime-store",
storage: createJSONStorage(() => localStorage),
// 민감정보(appKey/appSecret/accountNo)는 브라우저 세션 범위로만 유지합니다.
storage: createJSONStorage(() => sessionStorage),
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
},
partialize: (state) => ({
// 새로고침 시 인증이 풀리지 않도록, "세션 범위"에서만 인증/입력 상태를 유지합니다.
// 브라우저 종료 시 sessionStorage가 비워지므로 장기 영속(localStorage)은 하지 않습니다.
kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput,
@@ -254,7 +257,6 @@ export const useKisRuntimeStore = create<
isKisProfileVerified: state.isKisProfileVerified,
verifiedAccountNo: state.verifiedAccountNo,
tradingEnv: state.tradingEnv,
// wsApprovalKey/wsUrl are kept in memory only (expiration-sensitive).
}),
},
),

View File

@@ -1,4 +1,9 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import {
buildKisRequestHeaders,
resolveKisApiErrorMessage,
type KisApiErrorPayload,
} from "@/features/settings/apis/kis-api-utils";
import type {
DashboardChartTimeframe,
DashboardStockCashOrderRequest,
@@ -8,11 +13,6 @@ import type {
DashboardStockOverviewResponse,
DashboardStockSearchResponse,
} from "@/features/trade/types/trade.types";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
/**
* 종목 검색 API 호출
@@ -32,12 +32,10 @@ export async function fetchStockSearch(
const payload = (await response.json()) as
| DashboardStockSearchResponse
| { error?: string };
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "종목 검색 중 오류가 발생했습니다.",
);
throw new Error(resolveKisApiErrorMessage(payload, "종목 검색 중 오류가 발생했습니다."));
}
return payload as DashboardStockSearchResponse;
@@ -56,19 +54,19 @@ export async function fetchStockOverview(
`/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`,
{
method: "GET",
headers: buildKisRequestHeaders(credentials),
headers: buildKisRequestHeaders(credentials, {
includeSessionOverride: true,
}),
cache: "no-store",
},
);
const payload = (await response.json()) as
| DashboardStockOverviewResponse
| { error?: string };
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "종목 조회 중 오류가 발생했습니다.",
);
throw new Error(resolveKisApiErrorMessage(payload, "종목 조회 중 오류가 발생했습니다."));
}
return payload as DashboardStockOverviewResponse;
@@ -88,7 +86,9 @@ export async function fetchStockOrderBook(
`/api/kis/domestic/orderbook?symbol=${encodeURIComponent(symbol)}`,
{
method: "GET",
headers: buildKisRequestHeaders(credentials),
headers: buildKisRequestHeaders(credentials, {
includeSessionOverride: true,
}),
cache: "no-store",
signal,
},
@@ -96,12 +96,10 @@ export async function fetchStockOrderBook(
const payload = (await response.json()) as
| DashboardStockOrderBookResponse
| { error?: string };
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "호가 조회 중 오류가 발생했습니다.",
);
throw new Error(resolveKisApiErrorMessage(payload, "호가 조회 중 오류가 발생했습니다."));
}
return payload as DashboardStockOrderBookResponse;
@@ -124,18 +122,18 @@ export async function fetchStockChart(
const response = await fetch(`/api/kis/domestic/chart?${query.toString()}`, {
method: "GET",
headers: buildKisRequestHeaders(credentials),
headers: buildKisRequestHeaders(credentials, {
includeSessionOverride: true,
}),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardStockChartResponse
| { error?: string };
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "차트 조회 중 오류가 발생했습니다.",
);
throw new Error(resolveKisApiErrorMessage(payload, "차트 조회 중 오류가 발생했습니다."));
}
return payload as DashboardStockChartResponse;
@@ -152,51 +150,21 @@ export async function fetchOrderCash(
): Promise<DashboardStockCashOrderResponse> {
const response = await fetch("/api/kis/domestic/order-cash", {
method: "POST",
headers: buildKisRequestHeaders(credentials, { jsonContentType: true }),
headers: buildKisRequestHeaders(credentials, {
jsonContentType: true,
includeSessionOverride: true,
}),
body: JSON.stringify(request),
cache: "no-store",
});
const payload = (await response.json()) as DashboardStockCashOrderResponse;
const payload = (await response.json()) as
| DashboardStockCashOrderResponse
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(payload.message || "주문 전송 중 오류가 발생했습니다.");
throw new Error(resolveKisApiErrorMessage(payload, "주문 전송 중 오류가 발생했습니다."));
}
return payload;
return payload as DashboardStockCashOrderResponse;
}
function buildKisRequestHeaders(
credentials: KisRuntimeCredentials,
options?: { jsonContentType?: boolean },
) {
const headers: Record<string, string> = {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
};
if (options?.jsonContentType) {
headers["content-type"] = "application/json";
}
const sessionOverride = readSessionOverrideForDev();
if (sessionOverride) {
headers[DOMESTIC_KIS_SESSION_OVERRIDE_HEADER] = sessionOverride;
}
return headers;
}
function readSessionOverrideForDev() {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
);
return parseDomesticKisSession(raw);
} catch {
return null;
}
}

View File

@@ -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;
}

View 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;
}

View File

@@ -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);

View File

@@ -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>
);
}

View 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}%` }}
/>
);
}

View 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;
}

View File

@@ -1,126 +0,0 @@
/**
* @file features/trade/data/mock-stocks.ts
* @description 대시보드 1단계 UI 검증용 목업 종목 데이터
* @remarks
* - 한국투자증권 API 연동 전까지 화면 동작 검증에 사용합니다.
* - 2단계 이후 실제 화면은 app/api/kis/* 응답을 사용합니다.
* - 현재는 레거시/비교용 샘플 데이터로만 남겨둔 상태입니다.
*/
import type { DashboardStockItem } from "@/features/trade/types/trade.types";
/**
* 대시보드 목업 종목 목록
* @see app/(main)/dashboard/page.tsx DashboardPage가 DashboardMain을 통해 이 데이터를 조회합니다.
* @see features/trade/components/dashboard-main.tsx 검색/차트/지표 카드의 기본 데이터 소스입니다.
*/
export const MOCK_STOCKS: DashboardStockItem[] = [
{
symbol: "005930",
name: "삼성전자",
market: "KOSPI",
currentPrice: 78500,
change: 1200,
changeRate: 1.55,
open: 77300,
high: 78900,
low: 77000,
prevClose: 77300,
volume: 15234012,
candles: [
{ time: "09:00", price: 74400 },
{ time: "09:10", price: 74650 },
{ time: "09:20", price: 75100 },
{ time: "09:30", price: 74950 },
{ time: "09:40", price: 75300 },
{ time: "09:50", price: 75600 },
{ time: "10:00", price: 75400 },
{ time: "10:10", price: 75850 },
{ time: "10:20", price: 76100 },
{ time: "10:30", price: 75950 },
{ time: "10:40", price: 76350 },
{ time: "10:50", price: 76700 },
{ time: "11:00", price: 76900 },
{ time: "11:10", price: 77250 },
{ time: "11:20", price: 77100 },
{ time: "11:30", price: 77400 },
{ time: "11:40", price: 77700 },
{ time: "11:50", price: 78150 },
{ time: "12:00", price: 77900 },
{ time: "12:10", price: 78300 },
{ time: "12:20", price: 78500 },
],
},
{
symbol: "000660",
name: "SK하이닉스",
market: "KOSPI",
currentPrice: 214500,
change: -1500,
changeRate: -0.69,
open: 216000,
high: 218000,
low: 213000,
prevClose: 216000,
volume: 3210450,
candles: [
{ time: "09:00", price: 221000 },
{ time: "09:10", price: 220400 },
{ time: "09:20", price: 219900 },
{ time: "09:30", price: 220200 },
{ time: "09:40", price: 219300 },
{ time: "09:50", price: 218500 },
{ time: "10:00", price: 217900 },
{ time: "10:10", price: 218300 },
{ time: "10:20", price: 217600 },
{ time: "10:30", price: 216900 },
{ time: "10:40", price: 216500 },
{ time: "10:50", price: 216800 },
{ time: "11:00", price: 215900 },
{ time: "11:10", price: 215300 },
{ time: "11:20", price: 214800 },
{ time: "11:30", price: 215100 },
{ time: "11:40", price: 214200 },
{ time: "11:50", price: 214700 },
{ time: "12:00", price: 214300 },
{ time: "12:10", price: 214600 },
{ time: "12:20", price: 214500 },
],
},
{
symbol: "035420",
name: "NAVER",
market: "KOSPI",
currentPrice: 197800,
change: 2200,
changeRate: 1.12,
open: 195500,
high: 198600,
low: 194900,
prevClose: 195600,
volume: 1904123,
candles: [
{ time: "09:00", price: 191800 },
{ time: "09:10", price: 192400 },
{ time: "09:20", price: 193000 },
{ time: "09:30", price: 192700 },
{ time: "09:40", price: 193600 },
{ time: "09:50", price: 194200 },
{ time: "10:00", price: 194000 },
{ time: "10:10", price: 194900 },
{ time: "10:20", price: 195100 },
{ time: "10:30", price: 194700 },
{ time: "10:40", price: 195800 },
{ time: "10:50", price: 196400 },
{ time: "11:00", price: 196100 },
{ time: "11:10", price: 196900 },
{ time: "11:20", price: 197200 },
{ time: "11:30", price: 197000 },
{ time: "11:40", price: 197600 },
{ time: "11:50", price: 198000 },
{ time: "12:00", price: 197400 },
{ time: "12:10", price: 198300 },
{ time: "12:20", price: 197800 },
],
},
];

View File

@@ -1,10 +1,48 @@
import { useState, useCallback } from "react";
import { z } from "zod";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/trade/types/trade.types";
import { fetchOrderCash } from "@/features/trade/apis/kis-stock.api";
import { parseKisAccountParts } from "@/lib/kis/account";
const placeOrderRequestSchema = z
.object({
symbol: z.string().trim().regex(/^\d{6}$/),
side: z.enum(["buy", "sell"]),
orderType: z.enum(["limit", "market"]),
quantity: z.number().int().positive(),
price: z.number(),
accountNo: z.string().trim().min(1),
accountProductCode: z.string().trim().optional(),
})
.superRefine((request, ctx) => {
if (request.orderType === "limit" && request.price <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["price"],
message: "지정가 주문은 가격이 0보다 커야 합니다.",
});
}
if (request.orderType === "market" && request.price < 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["price"],
message: "시장가 주문은 가격이 0 이상이어야 합니다.",
});
}
if (!parseKisAccountParts(request.accountNo, request.accountProductCode)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["accountNo"],
message: "계좌번호 형식이 올바르지 않습니다. (8-2)",
});
}
});
export function useOrder() {
const [isLoading, setIsLoading] = useState(false);
@@ -28,6 +66,15 @@ export function useOrder() {
setResult(null);
try {
const validationResult = placeOrderRequestSchema.safeParse(request);
if (!validationResult.success) {
setError(
validationResult.error.issues[0]?.message ??
"주문 요청 값이 올바르지 않습니다.",
);
return null;
}
const data = await fetchOrderCash(request, credentials);
setResult(data);
return data;

View File

@@ -177,8 +177,17 @@ export interface DashboardStockCashOrderRequest {
orderType: DashboardOrderType;
quantity: number;
price: number;
/**
* KIS 계좌번호(권장: 8-2, 예: 12345678-01)
* @see lib/kis/account.ts parseKisAccountParts 서버 주문 라우트에서 8-2 파싱에 사용합니다.
*/
accountNo: string;
accountProductCode: string;
/**
* 계좌상품코드(2자리, 선택)
* @description accountNo가 8-2 형식이면 서버에서 자동 파싱합니다.
* @see app/api/kis/domestic/order-cash/route.ts 주문 요청 검증/계좌 파싱
*/
accountProductCode?: string;
}
/**