임시커밋
This commit is contained in:
@@ -1,82 +0,0 @@
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardKisRevokeResponse,
|
||||
DashboardKisValidateResponse,
|
||||
DashboardKisWsApprovalResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
interface KisApiBaseResponse {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
async function postKisAuthApi<T extends KisApiBaseResponse>(
|
||||
endpoint: string,
|
||||
credentials: KisRuntimeCredentials,
|
||||
fallbackErrorMessage: string,
|
||||
): Promise<T> {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as T;
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.message || fallbackErrorMessage);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description KIS API 키를 검증합니다.
|
||||
* @see app/api/kis/validate/route.ts
|
||||
*/
|
||||
export async function validateKisCredentials(
|
||||
credentials: KisRuntimeCredentials,
|
||||
): Promise<DashboardKisValidateResponse> {
|
||||
return postKisAuthApi<DashboardKisValidateResponse>(
|
||||
"/api/kis/validate",
|
||||
credentials,
|
||||
"API 키 검증에 실패했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description KIS 액세스 토큰을 폐기합니다.
|
||||
* @see app/api/kis/revoke/route.ts
|
||||
*/
|
||||
export async function revokeKisCredentials(
|
||||
credentials: KisRuntimeCredentials,
|
||||
): Promise<DashboardKisRevokeResponse> {
|
||||
return postKisAuthApi<DashboardKisRevokeResponse>(
|
||||
"/api/kis/revoke",
|
||||
credentials,
|
||||
"API 토큰 폐기에 실패했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 웹소켓 승인키와 WS URL을 조회합니다.
|
||||
* @see app/api/kis/ws/approval/route.ts
|
||||
*/
|
||||
export async function fetchKisWebSocketApproval(
|
||||
credentials: KisRuntimeCredentials,
|
||||
): Promise<DashboardKisWsApprovalResponse> {
|
||||
const payload = await postKisAuthApi<DashboardKisWsApprovalResponse>(
|
||||
"/api/kis/ws/approval",
|
||||
credentials,
|
||||
"웹소켓 승인키 발급에 실패했습니다.",
|
||||
);
|
||||
|
||||
if (!payload.approvalKey || !payload.wsUrl) {
|
||||
throw new Error(payload.message || "웹소켓 연결 정보가 누락되었습니다.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardChartTimeframe,
|
||||
DashboardStockCashOrderRequest,
|
||||
DashboardStockCashOrderResponse,
|
||||
DashboardStockChartResponse,
|
||||
DashboardStockOrderBookResponse,
|
||||
DashboardStockOverviewResponse,
|
||||
DashboardStockSearchResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
||||
parseDomesticKisSession,
|
||||
} from "@/lib/kis/domestic-market-session";
|
||||
|
||||
/**
|
||||
* 종목 검색 API 호출
|
||||
* @param keyword 검색어
|
||||
*/
|
||||
export async function fetchStockSearch(
|
||||
keyword: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DashboardStockSearchResponse> {
|
||||
const response = await fetch(
|
||||
`/api/kis/domestic/search?q=${encodeURIComponent(keyword)}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
const payload = (await response.json()) as
|
||||
| DashboardStockSearchResponse
|
||||
| { error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"error" in payload ? payload.error : "종목 검색 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
return payload as DashboardStockSearchResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종목 상세 개요 조회 API 호출
|
||||
* @param symbol 종목코드
|
||||
* @param credentials KIS 인증 정보
|
||||
*/
|
||||
export async function fetchStockOverview(
|
||||
symbol: string,
|
||||
credentials: KisRuntimeCredentials,
|
||||
): Promise<DashboardStockOverviewResponse> {
|
||||
const response = await fetch(
|
||||
`/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: buildKisRequestHeaders(credentials),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
|
||||
const payload = (await response.json()) as
|
||||
| DashboardStockOverviewResponse
|
||||
| { error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"error" in payload ? payload.error : "종목 조회 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
return payload as DashboardStockOverviewResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종목 호가 조회 API 호출
|
||||
* @param symbol 종목코드
|
||||
* @param credentials KIS 인증 정보
|
||||
*/
|
||||
export async function fetchStockOrderBook(
|
||||
symbol: string,
|
||||
credentials: KisRuntimeCredentials,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DashboardStockOrderBookResponse> {
|
||||
const response = await fetch(
|
||||
`/api/kis/domestic/orderbook?symbol=${encodeURIComponent(symbol)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: buildKisRequestHeaders(credentials),
|
||||
cache: "no-store",
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
const payload = (await response.json()) as
|
||||
| DashboardStockOrderBookResponse
|
||||
| { error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"error" in payload ? payload.error : "호가 조회 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
return payload as DashboardStockOrderBookResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종목 차트(분봉/일봉/주봉) 조회 API 호출
|
||||
*/
|
||||
export async function fetchStockChart(
|
||||
symbol: string,
|
||||
timeframe: DashboardChartTimeframe,
|
||||
credentials: KisRuntimeCredentials,
|
||||
cursor?: string,
|
||||
): Promise<DashboardStockChartResponse> {
|
||||
const query = new URLSearchParams({
|
||||
symbol,
|
||||
timeframe,
|
||||
});
|
||||
if (cursor) query.set("cursor", cursor);
|
||||
|
||||
const response = await fetch(`/api/kis/domestic/chart?${query.toString()}`, {
|
||||
method: "GET",
|
||||
headers: buildKisRequestHeaders(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as
|
||||
| DashboardStockChartResponse
|
||||
| { error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"error" in payload ? payload.error : "차트 조회 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
return payload as DashboardStockChartResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주식 현금 주문 API 호출
|
||||
* @param request 주문 요청 데이터
|
||||
* @param credentials KIS 인증 정보
|
||||
*/
|
||||
export async function fetchOrderCash(
|
||||
request: DashboardStockCashOrderRequest,
|
||||
credentials: KisRuntimeCredentials,
|
||||
): Promise<DashboardStockCashOrderResponse> {
|
||||
const response = await fetch("/api/kis/domestic/order-cash", {
|
||||
method: "POST",
|
||||
headers: buildKisRequestHeaders(credentials, { jsonContentType: true }),
|
||||
body: JSON.stringify(request),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardStockCashOrderResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || "주문 전송 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import { KisAuthForm } from "@/features/dashboard/components/auth/KisAuthForm";
|
||||
import { StockSearchForm } from "@/features/dashboard/components/search/StockSearchForm";
|
||||
import { StockSearchHistory } from "@/features/dashboard/components/search/StockSearchHistory";
|
||||
import { StockSearchResults } from "@/features/dashboard/components/search/StockSearchResults";
|
||||
import { useStockSearch } from "@/features/dashboard/hooks/useStockSearch";
|
||||
import { useOrderBook } from "@/features/dashboard/hooks/useOrderBook";
|
||||
import { useKisTradeWebSocket } from "@/features/dashboard/hooks/useKisTradeWebSocket";
|
||||
import { useStockOverview } from "@/features/dashboard/hooks/useStockOverview";
|
||||
import { useCurrentPrice } from "@/features/dashboard/hooks/useCurrentPrice";
|
||||
import { DashboardLayout } from "@/features/dashboard/components/layout/DashboardLayout";
|
||||
import { StockHeader } from "@/features/dashboard/components/header/StockHeader";
|
||||
import { OrderBook } from "@/features/dashboard/components/orderbook/OrderBook";
|
||||
import { OrderForm } from "@/features/dashboard/components/order/OrderForm";
|
||||
import { StockLineChart } from "@/features/dashboard/components/chart/StockLineChart";
|
||||
import type {
|
||||
DashboardStockOrderBookResponse,
|
||||
DashboardStockSearchItem,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
/**
|
||||
* @description 대시보드 메인 컨테이너
|
||||
* @see app/(main)/dashboard/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다.
|
||||
* @see features/dashboard/hooks/useStockSearch.ts 검색 입력/요청/히스토리 상태를 관리합니다.
|
||||
* @see features/dashboard/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
|
||||
*/
|
||||
export function DashboardContainer() {
|
||||
const skipNextAutoSearchRef = useRef(false);
|
||||
const hasInitializedAuthPanelRef = useRef(false);
|
||||
const searchShellRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 모바일에서는 초기 진입 시 API 패널을 접어 본문(차트/호가)을 먼저 보이게 합니다.
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||
const [isAuthPanelExpanded, setIsAuthPanelExpanded] = useState(true);
|
||||
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
|
||||
|
||||
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
isKisVerified: state.isKisVerified,
|
||||
})),
|
||||
);
|
||||
|
||||
const {
|
||||
keyword,
|
||||
setKeyword,
|
||||
searchResults,
|
||||
setSearchError,
|
||||
isSearching,
|
||||
search,
|
||||
clearSearch,
|
||||
searchHistory,
|
||||
appendSearchHistory,
|
||||
removeSearchHistory,
|
||||
clearSearchHistory,
|
||||
} = useStockSearch();
|
||||
|
||||
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
|
||||
useStockOverview();
|
||||
|
||||
// 호가 실시간 데이터 (체결 WS에서 동일 소켓으로 수신)
|
||||
const [realtimeOrderBook, setRealtimeOrderBook] =
|
||||
useState<DashboardStockOrderBookResponse | null>(null);
|
||||
|
||||
const handleOrderBookMessage = useCallback(
|
||||
(data: DashboardStockOrderBookResponse) => {
|
||||
setRealtimeOrderBook(data);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 1. Trade WebSocket (체결 + 호가 통합)
|
||||
const { latestTick, recentTradeTicks } = useKisTradeWebSocket(
|
||||
selectedStock?.symbol,
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
updateRealtimeTradeTick,
|
||||
{
|
||||
orderBookSymbol: selectedStock?.symbol,
|
||||
onOrderBookMessage: handleOrderBookMessage,
|
||||
},
|
||||
);
|
||||
|
||||
// 2. OrderBook (REST 초기 조회 + WS 실시간 병합)
|
||||
const { orderBook, isLoading: isOrderBookLoading } = useOrderBook(
|
||||
selectedStock?.symbol,
|
||||
selectedStock?.market,
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
{
|
||||
enabled: !!selectedStock && !!verifiedCredentials && isKisVerified,
|
||||
externalRealtimeOrderBook: realtimeOrderBook,
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Price Calculation Logic (Hook)
|
||||
const {
|
||||
currentPrice,
|
||||
change,
|
||||
changeRate,
|
||||
prevClose: referencePrice,
|
||||
} = useCurrentPrice({
|
||||
stock: selectedStock,
|
||||
latestTick,
|
||||
orderBook,
|
||||
});
|
||||
|
||||
const canSearch = isKisVerified && !!verifiedCredentials;
|
||||
|
||||
/**
|
||||
* @description 검색 전 API 인증 여부를 확인합니다.
|
||||
* @see features/dashboard/components/search/StockSearchForm.tsx 검색 제출 전 공통 가드로 사용합니다.
|
||||
*/
|
||||
const ensureSearchReady = useCallback(() => {
|
||||
if (canSearch) return true;
|
||||
setSearchError("API 키 검증을 먼저 완료해 주세요.");
|
||||
return false;
|
||||
}, [canSearch, setSearchError]);
|
||||
|
||||
const closeSearchPanel = useCallback(() => {
|
||||
setIsSearchPanelOpen(false);
|
||||
}, []);
|
||||
|
||||
const openSearchPanel = useCallback(() => {
|
||||
if (!canSearch) return;
|
||||
setIsSearchPanelOpen(true);
|
||||
}, [canSearch]);
|
||||
|
||||
/**
|
||||
* @description 검색 영역 포커스가 완전히 빠지면 드롭다운(검색결과/히스토리)을 닫습니다.
|
||||
* @see features/dashboard/components/search/StockSearchForm.tsx 입력 포커스 이벤트에서 열림 제어를 함께 사용합니다.
|
||||
*/
|
||||
const handleSearchShellBlur = useCallback(
|
||||
(event: React.FocusEvent<HTMLDivElement>) => {
|
||||
const nextTarget = event.relatedTarget as Node | null;
|
||||
if (nextTarget && searchShellRef.current?.contains(nextTarget)) return;
|
||||
closeSearchPanel();
|
||||
},
|
||||
[closeSearchPanel],
|
||||
);
|
||||
|
||||
const handleSearchShellKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== "Escape") return;
|
||||
closeSearchPanel();
|
||||
(event.target as HTMLElement | null)?.blur?.();
|
||||
},
|
||||
[closeSearchPanel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(max-width: 767px)");
|
||||
|
||||
const applyViewportMode = (matches: boolean) => {
|
||||
setIsMobileViewport(matches);
|
||||
|
||||
// 최초 1회: 모바일이면 접힘, 데스크탑이면 펼침.
|
||||
if (!hasInitializedAuthPanelRef.current) {
|
||||
setIsAuthPanelExpanded(!matches);
|
||||
hasInitializedAuthPanelRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 데스크탑으로 돌아오면 항상 펼쳐 사용성을 유지합니다.
|
||||
if (!matches) {
|
||||
setIsAuthPanelExpanded(true);
|
||||
}
|
||||
};
|
||||
|
||||
applyViewportMode(mediaQuery.matches);
|
||||
|
||||
const onViewportChange = (event: MediaQueryListEvent) => {
|
||||
applyViewportMode(event.matches);
|
||||
};
|
||||
mediaQuery.addEventListener("change", onViewportChange);
|
||||
|
||||
return () => mediaQuery.removeEventListener("change", onViewportChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipNextAutoSearchRef.current) {
|
||||
skipNextAutoSearchRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canSearch) {
|
||||
clearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = keyword.trim();
|
||||
if (!trimmed) {
|
||||
clearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
search(trimmed, verifiedCredentials);
|
||||
}, 220);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [canSearch, keyword, verifiedCredentials, search, clearSearch]);
|
||||
|
||||
/**
|
||||
* @description 수동 검색 버튼(엔터 포함) 제출 이벤트를 처리합니다.
|
||||
* @see features/dashboard/components/search/StockSearchForm.tsx onSubmit 이벤트로 호출됩니다.
|
||||
*/
|
||||
const handleSearchSubmit = useCallback(
|
||||
(event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!ensureSearchReady() || !verifiedCredentials) return;
|
||||
search(keyword, verifiedCredentials);
|
||||
},
|
||||
[ensureSearchReady, keyword, search, verifiedCredentials],
|
||||
);
|
||||
|
||||
/**
|
||||
* @description 검색 결과/히스토리에서 종목을 선택하면 종목 상세를 로드하고 히스토리를 갱신합니다.
|
||||
* @see features/dashboard/components/search/StockSearchResults.tsx onSelect 이벤트
|
||||
* @see features/dashboard/components/search/StockSearchHistory.tsx onSelect 이벤트
|
||||
*/
|
||||
const handleSelectStock = useCallback(
|
||||
(item: DashboardStockSearchItem) => {
|
||||
if (!ensureSearchReady() || !verifiedCredentials) return;
|
||||
|
||||
// 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다.
|
||||
if (selectedStock?.symbol === item.symbol) {
|
||||
clearSearch();
|
||||
closeSearchPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 실행되지 않게 한 번 건너뜁니다.
|
||||
skipNextAutoSearchRef.current = true;
|
||||
setKeyword(item.name);
|
||||
clearSearch();
|
||||
closeSearchPanel();
|
||||
appendSearchHistory(item);
|
||||
loadOverview(item.symbol, verifiedCredentials, item.market);
|
||||
},
|
||||
[
|
||||
ensureSearchReady,
|
||||
verifiedCredentials,
|
||||
selectedStock?.symbol,
|
||||
clearSearch,
|
||||
closeSearchPanel,
|
||||
setKeyword,
|
||||
appendSearchHistory,
|
||||
loadOverview,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex flex-col">
|
||||
{/* ========== AUTH STATUS ========== */}
|
||||
<div className="flex-none border-b bg-muted/40 transition-all duration-300 ease-in-out dark:border-brand-800/45 dark:bg-brand-900/28">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 text-xs sm:px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">KIS API 연결 상태:</span>
|
||||
{isKisVerified ? (
|
||||
<span className="flex items-center font-medium text-brand-700 dark:text-brand-200">
|
||||
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-brand-100 dark:ring-brand-900" />
|
||||
연결됨 ({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground flex items-center">
|
||||
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-200 dark:bg-brand-500/60" />
|
||||
미연결
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsAuthPanelExpanded((prev) => !prev)}
|
||||
className={cn(
|
||||
"h-8 shrink-0 gap-1.5 px-2.5 text-[11px] font-semibold",
|
||||
"border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 dark:border-brand-700/60 dark:bg-brand-900/30 dark:text-brand-200 dark:hover:bg-brand-900/45",
|
||||
!isAuthPanelExpanded &&
|
||||
isMobileViewport &&
|
||||
"ring-2 ring-brand-200 dark:ring-brand-600/60",
|
||||
)}
|
||||
>
|
||||
{isAuthPanelExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
API 설정 접기
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
API 설정 펼치기
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out",
|
||||
isAuthPanelExpanded ? "max-h-[560px] opacity-100" : "max-h-0 opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="border-t bg-background p-4 dark:border-brand-800/45 dark:bg-brand-900/14">
|
||||
<KisAuthForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== SEARCH ========== */}
|
||||
<div className="z-30 flex-none border-b bg-background/95 p-4 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22">
|
||||
<div
|
||||
ref={searchShellRef}
|
||||
onBlurCapture={handleSearchShellBlur}
|
||||
onKeyDownCapture={handleSearchShellKeyDown}
|
||||
className="relative mx-auto max-w-2xl"
|
||||
>
|
||||
<StockSearchForm
|
||||
keyword={keyword}
|
||||
onKeywordChange={setKeyword}
|
||||
onSubmit={handleSearchSubmit}
|
||||
onInputFocus={openSearchPanel}
|
||||
disabled={!canSearch}
|
||||
isLoading={isSearching}
|
||||
/>
|
||||
|
||||
{isSearchPanelOpen && canSearch && (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-x-hidden overflow-y-auto rounded-md border bg-background shadow-lg dark:border-brand-800/45 dark:bg-brand-950/95">
|
||||
{searchResults.length > 0 ? (
|
||||
<StockSearchResults
|
||||
items={searchResults}
|
||||
onSelect={handleSelectStock}
|
||||
selectedSymbol={selectedStock?.symbol}
|
||||
/>
|
||||
) : keyword.trim() ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{isSearching ? "검색 중..." : "검색 결과가 없습니다."}
|
||||
</div>
|
||||
) : searchHistory.length > 0 ? (
|
||||
<StockSearchHistory
|
||||
items={searchHistory}
|
||||
onSelect={handleSelectStock}
|
||||
onRemove={removeSearchHistory}
|
||||
onClear={clearSearchHistory}
|
||||
selectedSymbol={selectedStock?.symbol}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
최근 검색 종목이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== MAIN CONTENT ========== */}
|
||||
<div
|
||||
className={cn(
|
||||
"transition-opacity duration-200 h-full flex-1 min-h-0 overflow-x-hidden",
|
||||
!selectedStock && "opacity-20 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<DashboardLayout
|
||||
header={
|
||||
selectedStock ? (
|
||||
<StockHeader
|
||||
stock={selectedStock}
|
||||
price={currentPrice?.toLocaleString() ?? "0"}
|
||||
change={change?.toLocaleString() ?? "0"}
|
||||
changeRate={changeRate?.toFixed(2) ?? "0.00"}
|
||||
high={latestTick ? latestTick.high.toLocaleString() : undefined}
|
||||
low={latestTick ? latestTick.low.toLocaleString() : undefined}
|
||||
volume={
|
||||
latestTick
|
||||
? latestTick.accumulatedVolume.toLocaleString()
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
chart={
|
||||
selectedStock ? (
|
||||
<div className="p-0 h-full flex flex-col">
|
||||
<StockLineChart
|
||||
symbol={selectedStock.symbol}
|
||||
candles={selectedStock.candles}
|
||||
credentials={verifiedCredentials}
|
||||
latestTick={latestTick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
차트 영역
|
||||
</div>
|
||||
)
|
||||
}
|
||||
orderBook={
|
||||
<OrderBook
|
||||
symbol={selectedStock?.symbol}
|
||||
referencePrice={referencePrice}
|
||||
currentPrice={currentPrice}
|
||||
latestTick={latestTick}
|
||||
recentTicks={recentTradeTicks}
|
||||
orderBook={orderBook}
|
||||
isLoading={isOrderBookLoading}
|
||||
/>
|
||||
}
|
||||
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
import { useState, useTransition } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import {
|
||||
revokeKisCredentials,
|
||||
validateKisCredentials,
|
||||
} from "@/features/dashboard/apis/kis-auth.api";
|
||||
import {
|
||||
KeyRound,
|
||||
Shield,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Lock,
|
||||
Sparkles,
|
||||
Zap,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import { InlineSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
/**
|
||||
* @description KIS 인증 입력 폼 (Minimal Redesign v4)
|
||||
* - User Feedback: "입력창이 너무 길어", "파란색/하늘색 제거해"
|
||||
* - Compact Width: max-w-lg + mx-auto
|
||||
* - Monochrome Mock Mode: Blue -> Zinc/Gray
|
||||
*/
|
||||
export function KisAuthForm() {
|
||||
const {
|
||||
kisTradingEnvInput,
|
||||
kisAppKeyInput,
|
||||
kisAppSecretInput,
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
setKisTradingEnvInput,
|
||||
setKisAppKeyInput,
|
||||
setKisAppSecretInput,
|
||||
setVerifiedKisSession,
|
||||
invalidateKisVerification,
|
||||
clearKisRuntimeSession,
|
||||
} = useKisRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
kisTradingEnvInput: state.kisTradingEnvInput,
|
||||
kisAppKeyInput: state.kisAppKeyInput,
|
||||
kisAppSecretInput: state.kisAppSecretInput,
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
isKisVerified: state.isKisVerified,
|
||||
setKisTradingEnvInput: state.setKisTradingEnvInput,
|
||||
setKisAppKeyInput: state.setKisAppKeyInput,
|
||||
setKisAppSecretInput: state.setKisAppSecretInput,
|
||||
setVerifiedKisSession: state.setVerifiedKisSession,
|
||||
invalidateKisVerification: state.invalidateKisVerification,
|
||||
clearKisRuntimeSession: state.clearKisRuntimeSession,
|
||||
})),
|
||||
);
|
||||
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isValidating, startValidateTransition] = useTransition();
|
||||
const [isRevoking, startRevokeTransition] = useTransition();
|
||||
|
||||
// 입력 필드 Focus 상태 관리를 위한 State
|
||||
const [focusedField, setFocusedField] = useState<
|
||||
"appKey" | "appSecret" | null
|
||||
>(null);
|
||||
|
||||
function handleValidate() {
|
||||
startValidateTransition(async () => {
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
setStatusMessage(null);
|
||||
|
||||
const appKey = kisAppKeyInput.trim();
|
||||
const appSecret = kisAppSecretInput.trim();
|
||||
|
||||
if (!appKey || !appSecret) {
|
||||
throw new Error("App Key와 App Secret을 모두 입력해 주세요.");
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
appKey,
|
||||
appSecret,
|
||||
tradingEnv: kisTradingEnvInput,
|
||||
accountNo: verifiedCredentials?.accountNo ?? "",
|
||||
};
|
||||
|
||||
const result = await validateKisCredentials(credentials);
|
||||
setVerifiedKisSession(credentials, result.tradingEnv);
|
||||
setStatusMessage(
|
||||
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
|
||||
);
|
||||
} catch (err) {
|
||||
invalidateKisVerification();
|
||||
setErrorMessage(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "API 키 검증 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRevoke() {
|
||||
if (!verifiedCredentials) return;
|
||||
|
||||
startRevokeTransition(async () => {
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
setStatusMessage(null);
|
||||
const result = await revokeKisCredentials(verifiedCredentials);
|
||||
clearKisRuntimeSession(result.tradingEnv);
|
||||
setStatusMessage(
|
||||
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
|
||||
);
|
||||
} catch (err) {
|
||||
setErrorMessage(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "연결 해제 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group relative mx-auto w-full max-w-lg overflow-hidden rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm transition-all hover:border-brand-200 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900/50 dark:hover:border-brand-800 dark:hover:shadow-brand-900/10">
|
||||
{/* Inner Content Container - Compact spacing */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header Section */}
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-brand-50 text-brand-600 ring-1 ring-brand-100 dark:bg-brand-900/20 dark:text-brand-400 dark:ring-brand-800/50">
|
||||
<KeyRound className="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-base font-bold tracking-tight text-zinc-800 dark:text-zinc-100">
|
||||
KIS API Key Connection
|
||||
{isKisVerified && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-1.5 py-0.5 text-[10px] font-medium text-green-600 ring-1 ring-green-600/10 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/30">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400">
|
||||
한국투자증권에서 발급받은 API 키를 입력해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trading Mode Switch (Segmented Control - Compact) */}
|
||||
<div className="flex shrink-0 items-center rounded-md bg-zinc-100 p-0.5 ring-1 ring-zinc-200 dark:bg-zinc-800 dark:ring-zinc-700">
|
||||
<button
|
||||
onClick={() => setKisTradingEnvInput("real")}
|
||||
className={cn(
|
||||
"relative flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-[11px] font-semibold transition-all duration-200",
|
||||
kisTradingEnvInput === "real"
|
||||
? "bg-white text-brand-600 shadow-sm ring-1 ring-black/5 dark:bg-brand-500 dark:text-white dark:ring-brand-400"
|
||||
: "text-zinc-500 hover:text-zinc-700 hover:bg-black/5 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-white/5",
|
||||
)}
|
||||
>
|
||||
<Zap
|
||||
className={cn(
|
||||
"h-3 w-3",
|
||||
kisTradingEnvInput === "real"
|
||||
? "text-brand-500 dark:text-white"
|
||||
: "text-zinc-400",
|
||||
)}
|
||||
/>
|
||||
실전 투자
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setKisTradingEnvInput("mock")}
|
||||
className={cn(
|
||||
"relative flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-[11px] font-semibold transition-all duration-200",
|
||||
kisTradingEnvInput === "mock"
|
||||
? "bg-white text-zinc-800 shadow-sm ring-1 ring-black/5 dark:bg-zinc-600 dark:text-white dark:ring-zinc-500"
|
||||
: "text-zinc-500 hover:text-zinc-700 hover:bg-black/5 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-white/5",
|
||||
)}
|
||||
>
|
||||
<Activity
|
||||
className={cn(
|
||||
"h-3 w-3",
|
||||
kisTradingEnvInput === "mock"
|
||||
? "text-zinc-800 dark:text-zinc-200"
|
||||
: "text-zinc-400",
|
||||
)}
|
||||
/>
|
||||
모의 투자
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Fields Section (Compact Stacked Layout) */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* App Key Input */}
|
||||
<div
|
||||
className={cn(
|
||||
"group/input relative flex items-center overflow-hidden rounded-lg border bg-white transition-all duration-200 dark:bg-zinc-900/30",
|
||||
focusedField === "appKey"
|
||||
? "border-brand-500 ring-1 ring-brand-500"
|
||||
: "border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600",
|
||||
)}
|
||||
>
|
||||
<div className="hidden h-9 w-9 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 transition-colors group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-500 dark:group-focus-within/input:text-brand-400 sm:flex">
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex h-9 min-w-[70px] items-center justify-center bg-zinc-50 px-2 text-[11px] font-semibold text-zinc-500 border-r border-zinc-100 sm:hidden dark:bg-zinc-800/50 dark:text-zinc-500 dark:border-zinc-800">
|
||||
App Key
|
||||
</div>
|
||||
<Input
|
||||
type="password"
|
||||
value={kisAppKeyInput}
|
||||
onChange={(e) => setKisAppKeyInput(e.target.value)}
|
||||
onFocus={() => setFocusedField("appKey")}
|
||||
onBlur={() => setFocusedField(null)}
|
||||
placeholder="App Key 입력"
|
||||
className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* App Secret Input */}
|
||||
<div
|
||||
className={cn(
|
||||
"group/input relative flex items-center overflow-hidden rounded-lg border bg-white transition-all duration-200 dark:bg-zinc-900/30",
|
||||
focusedField === "appSecret"
|
||||
? "border-brand-500 ring-1 ring-brand-500"
|
||||
: "border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600",
|
||||
)}
|
||||
>
|
||||
<div className="hidden h-9 w-9 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 transition-colors group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-500 dark:group-focus-within/input:text-brand-400 sm:flex">
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex h-9 min-w-[70px] items-center justify-center bg-zinc-50 px-2 text-[11px] font-semibold text-zinc-500 border-r border-zinc-100 sm:hidden dark:bg-zinc-800/50 dark:text-zinc-500 dark:border-zinc-800">
|
||||
Secret
|
||||
</div>
|
||||
<Input
|
||||
type="password"
|
||||
value={kisAppSecretInput}
|
||||
onChange={(e) => setKisAppSecretInput(e.target.value)}
|
||||
onFocus={() => setFocusedField("appSecret")}
|
||||
onBlur={() => setFocusedField(null)}
|
||||
placeholder="App Secret 입력"
|
||||
className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action & Status Section */}
|
||||
<div className="flex items-center justify-between border-t border-zinc-100 pt-4 dark:border-zinc-800/50">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
disabled={
|
||||
isValidating ||
|
||||
!kisAppKeyInput.trim() ||
|
||||
!kisAppSecretInput.trim()
|
||||
}
|
||||
className="h-9 rounded-lg bg-brand-600 px-4 text-xs font-semibold text-white shadow-sm transition-all hover:bg-brand-700 hover:shadow disabled:opacity-50 disabled:shadow-none dark:bg-brand-600 dark:hover:bg-brand-500"
|
||||
>
|
||||
{isValidating ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<InlineSpinner className="text-white h-3 w-3" />
|
||||
검증 중
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Sparkles className="h-3 w-3 fill-brand-200 text-brand-200" />
|
||||
API 키 연결
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isKisVerified && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRevoke}
|
||||
disabled={isRevoking}
|
||||
className="h-9 rounded-lg border-zinc-200 bg-white text-xs text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||
>
|
||||
{isRevoking ? "해제 중" : "연결 해제"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Messages - Compact */}
|
||||
<div className="flex-1 text-right">
|
||||
{errorMessage && (
|
||||
<p className="animate-in fade-in slide-in-from-right-4 flex justify-end gap-1.5 text-[11px] font-semibold text-red-500">
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
{statusMessage && (
|
||||
<p className="animate-in fade-in slide-in-from-right-4 flex justify-end gap-1.5 text-[11px] font-semibold text-brand-600 dark:text-brand-400">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
{!errorMessage && !statusMessage && !isKisVerified && (
|
||||
<p className="flex justify-end gap-1.5 text-[11px] text-zinc-400 dark:text-zinc-600">
|
||||
<span className="h-1.5 w-1.5 translate-y-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
||||
미연결
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,692 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CandlestickSeries,
|
||||
ColorType,
|
||||
HistogramSeries,
|
||||
createChart,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
type Time,
|
||||
} from "lightweight-charts";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
import { fetchStockChart } from "@/features/dashboard/apis/kis-stock.api";
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardChartTimeframe,
|
||||
DashboardRealtimeTradeTick,
|
||||
StockCandlePoint,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type ChartBar,
|
||||
formatKstCrosshairTime,
|
||||
formatKstTickMark,
|
||||
formatPrice,
|
||||
formatSignedPercent,
|
||||
isMinuteTimeframe,
|
||||
mergeBars,
|
||||
normalizeCandles,
|
||||
toRealtimeTickBar,
|
||||
upsertRealtimeBar,
|
||||
} from "./chart-utils";
|
||||
|
||||
const UP_COLOR = "#ef4444";
|
||||
const MINUTE_SYNC_INTERVAL_MS = 5000;
|
||||
const REALTIME_STALE_THRESHOLD_MS = 12000;
|
||||
|
||||
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: "주" },
|
||||
];
|
||||
|
||||
interface StockLineChartProps {
|
||||
symbol?: string;
|
||||
candles: StockCandlePoint[];
|
||||
credentials?: KisRuntimeCredentials | null;
|
||||
latestTick?: DashboardRealtimeTradeTick | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description TradingView 스타일 캔들 차트를 렌더링하고, timeframe별 KIS 차트 API를 조회합니다.
|
||||
* @see features/dashboard/apis/kis-stock.api.ts fetchStockChart
|
||||
* @see lib/kis/domestic.ts getDomesticChart
|
||||
*/
|
||||
export function StockLineChart({
|
||||
symbol,
|
||||
candles,
|
||||
credentials,
|
||||
latestTick,
|
||||
}: StockLineChartProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const candleSeriesRef = useRef<ISeriesApi<"Candlestick", Time> | null>(null);
|
||||
const volumeSeriesRef = useRef<ISeriesApi<"Histogram", Time> | null>(null);
|
||||
|
||||
const [timeframe, setTimeframe] = useState<DashboardChartTimeframe>("1d");
|
||||
const [isMinuteDropdownOpen, setIsMinuteDropdownOpen] = useState(false);
|
||||
const [bars, setBars] = useState<ChartBar[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [isChartReady, setIsChartReady] = useState(false);
|
||||
const lastRealtimeKeyRef = useRef<string>("");
|
||||
const lastRealtimeAppliedAtRef = useRef(0);
|
||||
const chartPaletteRef = useRef<ChartPalette>(DEFAULT_CHART_PALETTE);
|
||||
const renderableBarsRef = useRef<ChartBar[]>([]);
|
||||
|
||||
const activeThemeMode: "light" | "dark" =
|
||||
resolvedTheme === "dark"
|
||||
? "dark"
|
||||
: resolvedTheme === "light"
|
||||
? "light"
|
||||
: typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark")
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
// 복수 이벤트에서 중복 로드를 막기 위한 ref 상태
|
||||
const loadingMoreRef = useRef(false);
|
||||
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
|
||||
// API 오류 시 fallback 용도로 유지
|
||||
const latestCandlesRef = useRef(candles);
|
||||
useEffect(() => {
|
||||
latestCandlesRef.current = candles;
|
||||
}, [candles]);
|
||||
|
||||
const latest = bars.at(-1);
|
||||
const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0;
|
||||
const change = latest ? latest.close - prevClose : 0;
|
||||
const changeRate = prevClose > 0 ? (change / prevClose) * 100 : 0;
|
||||
|
||||
const renderableBars = useMemo(() => {
|
||||
const dedup = new Map<number, ChartBar>();
|
||||
|
||||
for (const bar of bars) {
|
||||
if (
|
||||
!Number.isFinite(bar.time) ||
|
||||
!Number.isFinite(bar.open) ||
|
||||
!Number.isFinite(bar.high) ||
|
||||
!Number.isFinite(bar.low) ||
|
||||
!Number.isFinite(bar.close) ||
|
||||
bar.close <= 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
dedup.set(bar.time, bar);
|
||||
}
|
||||
|
||||
return [...dedup.values()].sort((a, b) => a.time - b.time);
|
||||
}, [bars]);
|
||||
|
||||
useEffect(() => {
|
||||
renderableBarsRef.current = renderableBars;
|
||||
}, [renderableBars]);
|
||||
|
||||
/**
|
||||
* @description lightweight-charts 시리즈 데이터에 안전한 OHLCV 값을 주입합니다.
|
||||
* @see features/dashboard/components/chart/StockLineChart.tsx renderableBars useMemo
|
||||
*/
|
||||
const setSeriesData = useCallback((nextBars: ChartBar[]) => {
|
||||
const candleSeries = candleSeriesRef.current;
|
||||
const volumeSeries = volumeSeriesRef.current;
|
||||
if (!candleSeries || !volumeSeries) return;
|
||||
|
||||
try {
|
||||
candleSeries.setData(
|
||||
nextBars.map((bar) => ({
|
||||
time: bar.time,
|
||||
open: bar.open,
|
||||
high: bar.high,
|
||||
low: bar.low,
|
||||
close: bar.close,
|
||||
})),
|
||||
);
|
||||
|
||||
volumeSeries.setData(
|
||||
nextBars.map((bar) => ({
|
||||
time: bar.time,
|
||||
value: Number.isFinite(bar.volume) ? bar.volume : 0,
|
||||
color:
|
||||
bar.close >= bar.open
|
||||
? "rgba(239,68,68,0.45)"
|
||||
: chartPaletteRef.current.volumeDownColor,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to render chart series data:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* @description 좌측 스크롤 시 cursor 기반 과거 캔들을 추가 로드합니다.
|
||||
* @see lib/kis/domestic.ts getDomesticChart cursor
|
||||
*/
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current) return;
|
||||
|
||||
loadingMoreRef.current = true;
|
||||
setIsLoadingMore(true);
|
||||
|
||||
try {
|
||||
const response = await fetchStockChart(
|
||||
symbol,
|
||||
timeframe,
|
||||
credentials,
|
||||
nextCursor,
|
||||
);
|
||||
|
||||
const olderBars = normalizeCandles(response.candles, timeframe);
|
||||
setBars((prev) => mergeBars(olderBars, prev));
|
||||
setNextCursor(response.hasMore ? response.nextCursor : null);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "과거 차트 데이터를 불러오지 못했습니다.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
loadingMoreRef.current = false;
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [credentials, nextCursor, symbol, timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMoreHandlerRef.current = handleLoadMore;
|
||||
}, [handleLoadMore]);
|
||||
|
||||
useEffect(() => {
|
||||
lastRealtimeKeyRef.current = "";
|
||||
lastRealtimeAppliedAtRef.current = 0;
|
||||
}, [symbol, timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || chartRef.current) return;
|
||||
|
||||
// 브랜드 색상은 globals.css의 CSS 변수에서 읽어 차트(canvas)에도 동일하게 적용합니다.
|
||||
const palette = getChartPaletteFromCssVars(activeThemeMode);
|
||||
chartPaletteRef.current = palette;
|
||||
|
||||
const chart = createChart(container, {
|
||||
width: Math.max(container.clientWidth, 320),
|
||||
height: Math.max(container.clientHeight, 340),
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: palette.backgroundColor },
|
||||
textColor: palette.textColor,
|
||||
attributionLogo: true,
|
||||
},
|
||||
localization: {
|
||||
locale: "ko-KR",
|
||||
timeFormatter: formatKstCrosshairTime,
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: palette.borderColor,
|
||||
scaleMargins: {
|
||||
top: 0.08,
|
||||
bottom: 0.24,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: palette.gridColor },
|
||||
horzLines: { color: palette.gridColor },
|
||||
},
|
||||
crosshair: {
|
||||
vertLine: { color: palette.crosshairColor, width: 1, style: 2 },
|
||||
horzLine: { color: palette.crosshairColor, width: 1, style: 2 },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: palette.borderColor,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
rightOffset: 2,
|
||||
tickMarkFormatter: formatKstTickMark,
|
||||
},
|
||||
handleScroll: {
|
||||
mouseWheel: true,
|
||||
pressedMouseMove: true,
|
||||
},
|
||||
handleScale: {
|
||||
mouseWheel: true,
|
||||
pinch: true,
|
||||
axisPressedMouseMove: true,
|
||||
},
|
||||
});
|
||||
|
||||
const candleSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: UP_COLOR,
|
||||
downColor: palette.downColor,
|
||||
wickUpColor: UP_COLOR,
|
||||
wickDownColor: palette.downColor,
|
||||
borderUpColor: UP_COLOR,
|
||||
borderDownColor: palette.downColor,
|
||||
priceLineVisible: true,
|
||||
lastValueVisible: true,
|
||||
});
|
||||
|
||||
const volumeSeries = chart.addSeries(HistogramSeries, {
|
||||
priceScaleId: "volume",
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
base: 0,
|
||||
});
|
||||
|
||||
chart.priceScale("volume").applyOptions({
|
||||
scaleMargins: {
|
||||
top: 0.78,
|
||||
bottom: 0,
|
||||
},
|
||||
borderVisible: false,
|
||||
});
|
||||
|
||||
let scrollTimeout: number | undefined;
|
||||
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
|
||||
if (!range || !initialLoadCompleteRef.current) return;
|
||||
if (range.from >= 10) return;
|
||||
|
||||
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
|
||||
scrollTimeout = window.setTimeout(() => {
|
||||
void loadMoreHandlerRef.current();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
chartRef.current = chart;
|
||||
candleSeriesRef.current = candleSeries;
|
||||
volumeSeriesRef.current = volumeSeries;
|
||||
setIsChartReady(true);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
chart.resize(
|
||||
Math.max(container.clientWidth, 320),
|
||||
Math.max(container.clientHeight, 340),
|
||||
);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
chart.resize(
|
||||
Math.max(container.clientWidth, 320),
|
||||
Math.max(container.clientHeight, 340),
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
|
||||
window.cancelAnimationFrame(rafId);
|
||||
resizeObserver.disconnect();
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
candleSeriesRef.current = null;
|
||||
volumeSeriesRef.current = null;
|
||||
setIsChartReady(false);
|
||||
};
|
||||
}, [activeThemeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const chart = chartRef.current;
|
||||
const candleSeries = candleSeriesRef.current;
|
||||
if (!chart || !candleSeries) return;
|
||||
|
||||
const palette = getChartPaletteFromCssVars(activeThemeMode);
|
||||
chartPaletteRef.current = palette;
|
||||
|
||||
chart.applyOptions({
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: palette.backgroundColor },
|
||||
textColor: palette.textColor,
|
||||
},
|
||||
rightPriceScale: { borderColor: palette.borderColor },
|
||||
grid: {
|
||||
vertLines: { color: palette.gridColor },
|
||||
horzLines: { color: palette.gridColor },
|
||||
},
|
||||
crosshair: {
|
||||
vertLine: { color: palette.crosshairColor, width: 1, style: 2 },
|
||||
horzLine: { color: palette.crosshairColor, width: 1, style: 2 },
|
||||
},
|
||||
timeScale: { borderColor: palette.borderColor },
|
||||
});
|
||||
|
||||
candleSeries.applyOptions({
|
||||
downColor: palette.downColor,
|
||||
wickDownColor: palette.downColor,
|
||||
borderDownColor: palette.downColor,
|
||||
});
|
||||
|
||||
setSeriesData(renderableBarsRef.current);
|
||||
}, [activeThemeMode, setSeriesData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (symbol && credentials) return;
|
||||
|
||||
// 인증 전/종목 미선택 상태는 overview 캔들로 fallback
|
||||
setBars(normalizeCandles(candles, "1d"));
|
||||
setNextCursor(null);
|
||||
}, [candles, credentials, symbol]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!symbol || !credentials) return;
|
||||
|
||||
initialLoadCompleteRef.current = false;
|
||||
let disposed = false;
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const firstPage = await fetchStockChart(symbol, timeframe, credentials);
|
||||
if (disposed) return;
|
||||
|
||||
let mergedBars = normalizeCandles(firstPage.candles, timeframe);
|
||||
let resolvedNextCursor = firstPage.hasMore ? firstPage.nextCursor : null;
|
||||
|
||||
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
|
||||
if (
|
||||
isMinuteTimeframe(timeframe) &&
|
||||
firstPage.hasMore &&
|
||||
firstPage.nextCursor
|
||||
) {
|
||||
let minuteCursor: string | null = firstPage.nextCursor;
|
||||
let extraPageCount = 0;
|
||||
|
||||
while (minuteCursor && extraPageCount < 2) {
|
||||
try {
|
||||
const olderPage = await fetchStockChart(
|
||||
symbol,
|
||||
timeframe,
|
||||
credentials,
|
||||
minuteCursor,
|
||||
);
|
||||
|
||||
const olderBars = normalizeCandles(olderPage.candles, timeframe);
|
||||
mergedBars = mergeBars(olderBars, mergedBars);
|
||||
resolvedNextCursor = olderPage.hasMore ? olderPage.nextCursor : null;
|
||||
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null;
|
||||
extraPageCount += 1;
|
||||
} catch {
|
||||
// 추가 페이지 실패는 치명적이지 않으므로 현재 데이터는 유지합니다.
|
||||
minuteCursor = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setBars(mergedBars);
|
||||
setNextCursor(resolvedNextCursor);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!disposed) initialLoadCompleteRef.current = true;
|
||||
}, 350);
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "차트 조회 중 오류가 발생했습니다.";
|
||||
toast.error(message);
|
||||
|
||||
setBars(normalizeCandles(latestCandlesRef.current, timeframe));
|
||||
setNextCursor(null);
|
||||
} finally {
|
||||
if (!disposed) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [credentials, symbol, timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isChartReady) return;
|
||||
|
||||
setSeriesData(renderableBars);
|
||||
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
}
|
||||
}, [isChartReady, renderableBars, setSeriesData]);
|
||||
|
||||
/**
|
||||
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
|
||||
* @see features/dashboard/hooks/useKisTradeWebSocket.ts latestTick
|
||||
* @see features/dashboard/components/chart/chart-utils.ts toRealtimeTickBar
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!latestTick) return;
|
||||
if (bars.length === 0) return;
|
||||
|
||||
const dedupeKey = `${timeframe}:${latestTick.tickTime}:${latestTick.price}:${latestTick.tradeVolume}`;
|
||||
if (lastRealtimeKeyRef.current === dedupeKey) return;
|
||||
|
||||
const realtimeBar = toRealtimeTickBar(latestTick, timeframe);
|
||||
if (!realtimeBar) return;
|
||||
|
||||
lastRealtimeKeyRef.current = dedupeKey;
|
||||
lastRealtimeAppliedAtRef.current = Date.now();
|
||||
setBars((prev) => upsertRealtimeBar(prev, realtimeBar));
|
||||
}, [bars.length, latestTick, timeframe]);
|
||||
|
||||
/**
|
||||
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
|
||||
* @see features/dashboard/apis/kis-stock.api.ts fetchStockChart
|
||||
* @see lib/kis/domestic.ts getDomesticChart
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!symbol || !credentials) return;
|
||||
if (!isMinuteTimeframe(timeframe)) return;
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const syncLatestMinuteBars = async () => {
|
||||
const now = Date.now();
|
||||
const isRealtimeFresh =
|
||||
now - lastRealtimeAppliedAtRef.current < REALTIME_STALE_THRESHOLD_MS;
|
||||
if (isRealtimeFresh) return;
|
||||
|
||||
try {
|
||||
const response = await fetchStockChart(symbol, timeframe, credentials);
|
||||
if (disposed) return;
|
||||
|
||||
const latestPageBars = normalizeCandles(response.candles, timeframe);
|
||||
const recentBars = latestPageBars.slice(-10);
|
||||
if (recentBars.length === 0) return;
|
||||
|
||||
setBars((prev) => mergeBars(prev, recentBars));
|
||||
} catch {
|
||||
// 폴링 실패는 치명적이지 않으므로 조용히 다음 주기에서 재시도합니다.
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void syncLatestMinuteBars();
|
||||
}, MINUTE_SYNC_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [credentials, symbol, timeframe]);
|
||||
|
||||
const statusMessage = (() => {
|
||||
if (isLoading && bars.length === 0) {
|
||||
return "차트 데이터를 불러오는 중입니다.";
|
||||
}
|
||||
if (bars.length === 0) {
|
||||
return "차트 데이터가 없습니다.";
|
||||
}
|
||||
if (renderableBars.length === 0) {
|
||||
return "차트 데이터 형식이 올바르지 않습니다.";
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[340px] flex-col bg-white dark:bg-brand-900/10">
|
||||
{/* ========== CHART TOOLBAR ========== */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-brand-100 bg-muted/20 px-2 py-2 sm:px-3 dark:border-brand-800/45 dark:bg-brand-900/35">
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMinuteDropdownOpen((prev) => !prev)}
|
||||
onBlur={() =>
|
||||
window.setTimeout(() => setIsMinuteDropdownOpen(false), 200)
|
||||
}
|
||||
className={cn(
|
||||
"relative flex items-center gap-1 border-b-2 border-transparent px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground dark:text-brand-100/72 dark:hover:text-brand-50",
|
||||
MINUTE_TIMEFRAMES.some((item) => item.value === timeframe) &&
|
||||
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
|
||||
)}
|
||||
>
|
||||
{MINUTE_TIMEFRAMES.find((item) => item.value === timeframe)
|
||||
?.label ?? "분봉"}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
{isMinuteDropdownOpen && (
|
||||
<div className="absolute left-0 top-full z-10 mt-1 rounded border border-brand-100 bg-white shadow-lg dark:border-brand-700/45 dark:bg-[#1a1624]">
|
||||
{MINUTE_TIMEFRAMES.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTimeframe(item.value);
|
||||
setIsMinuteDropdownOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"block w-full whitespace-nowrap px-3 py-1.5 text-left hover:bg-brand-50 dark:text-brand-100 dark:hover:bg-brand-800/35",
|
||||
timeframe === item.value &&
|
||||
"bg-brand-50 font-semibold text-brand-700 dark:bg-brand-700/30 dark:text-brand-100",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{PERIOD_TIMEFRAMES.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => setTimeframe(item.value)}
|
||||
className={cn(
|
||||
"relative border-b-2 border-transparent px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground dark:text-brand-100/72 dark:hover:text-brand-50",
|
||||
timeframe === item.value &&
|
||||
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{isLoadingMore && (
|
||||
<span className="ml-2 text-[11px] text-muted-foreground dark:text-brand-200/70">
|
||||
과거 데이터 로딩 중...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground dark:text-brand-100/85 sm:text-xs">
|
||||
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)} L{" "}
|
||||
{formatPrice(latest?.low ?? 0)} C{" "}
|
||||
<span
|
||||
className={cn(
|
||||
change >= 0 ? "text-red-600" : "text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== CHART BODY ========== */}
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
|
||||
{statusMessage && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 text-sm text-muted-foreground dark:bg-background/90 dark:text-brand-100/80">
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
/**
|
||||
* @file chart-utils.ts
|
||||
* @description StockLineChart에서 사용하는 유틸리티 함수 모음
|
||||
*/
|
||||
|
||||
import type {
|
||||
TickMarkType,
|
||||
Time,
|
||||
UTCTimestamp,
|
||||
} from "lightweight-charts";
|
||||
import type {
|
||||
DashboardChartTimeframe,
|
||||
DashboardRealtimeTradeTick,
|
||||
StockCandlePoint,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
|
||||
const KST_TIME_ZONE = "Asia/Seoul";
|
||||
const KST_TIME_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const KST_TIME_SECONDS_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const KST_DATE_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const KST_MONTH_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
month: "short",
|
||||
});
|
||||
const KST_YEAR_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
year: "numeric",
|
||||
});
|
||||
const KST_CROSSHAIR_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────────────
|
||||
|
||||
export type ChartBar = {
|
||||
time: UTCTimestamp;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
};
|
||||
|
||||
// ─── StockCandlePoint → ChartBar 변환 ─────────────────────
|
||||
|
||||
/**
|
||||
* candles 배열을 ChartBar 배열로 정규화 (무효값 필터 + 병합 + 정렬)
|
||||
*/
|
||||
export function normalizeCandles(
|
||||
candles: StockCandlePoint[],
|
||||
timeframe: DashboardChartTimeframe,
|
||||
): ChartBar[] {
|
||||
const rows = candles
|
||||
.map((c) => convertCandleToBar(c, timeframe))
|
||||
.filter((b): b is ChartBar => Boolean(b));
|
||||
return mergeBars([], rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 candle → ChartBar 변환. 유효하지 않으면 null
|
||||
*/
|
||||
export function convertCandleToBar(
|
||||
candle: StockCandlePoint,
|
||||
timeframe: DashboardChartTimeframe,
|
||||
): ChartBar | null {
|
||||
const close = candle.close ?? candle.price;
|
||||
if (!Number.isFinite(close) || close <= 0) return null;
|
||||
|
||||
const open = candle.open ?? close;
|
||||
const high = candle.high ?? Math.max(open, close);
|
||||
const low = candle.low ?? Math.min(open, close);
|
||||
const volume = candle.volume ?? 0;
|
||||
const time = resolveBarTimestamp(candle, timeframe);
|
||||
if (!time) return null;
|
||||
|
||||
return {
|
||||
time,
|
||||
open,
|
||||
high: Math.max(high, open, close),
|
||||
low: Math.min(low, open, close),
|
||||
close,
|
||||
volume,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 타임스탬프 해석/정렬 ─────────────────────────────────
|
||||
|
||||
function resolveBarTimestamp(
|
||||
candle: StockCandlePoint,
|
||||
timeframe: DashboardChartTimeframe,
|
||||
): UTCTimestamp | null {
|
||||
// timestamp 필드가 있으면 우선 사용
|
||||
if (
|
||||
typeof candle.timestamp === "number" &&
|
||||
Number.isFinite(candle.timestamp)
|
||||
) {
|
||||
return alignTimestamp(candle.timestamp, timeframe);
|
||||
}
|
||||
|
||||
const text = typeof candle.time === "string" ? candle.time.trim() : "";
|
||||
if (!text) return null;
|
||||
|
||||
// "MM/DD" 형식 (일봉)
|
||||
if (/^\d{2}\/\d{2}$/.test(text)) {
|
||||
const [mm, dd] = text.split("/");
|
||||
const year = new Date().getFullYear();
|
||||
const ts = Math.floor(
|
||||
new Date(`${year}-${mm}-${dd}T09:00:00+09:00`).getTime() / 1000,
|
||||
);
|
||||
return alignTimestamp(ts, timeframe);
|
||||
}
|
||||
|
||||
// "HH:MM" 또는 "HH:MM:SS" 형식 (분봉)
|
||||
if (/^\d{2}:\d{2}(:\d{2})?$/.test(text)) {
|
||||
const [hh, mi, ss] = text.split(":");
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = `${now.getMonth() + 1}`.padStart(2, "0");
|
||||
const d = `${now.getDate()}`.padStart(2, "0");
|
||||
const ts = Math.floor(
|
||||
new Date(`${y}-${m}-${d}T${hh}:${mi}:${ss ?? "00"}+09:00`).getTime() /
|
||||
1000,
|
||||
);
|
||||
return alignTimestamp(ts, timeframe);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임스탬프를 타임프레임 버킷 경계에 정렬
|
||||
* - 1m: 그대로
|
||||
* - 30m/1h: 분 단위를 버킷에 정렬
|
||||
* - 1d: 00:00:00
|
||||
* - 1w: 월요일 00:00:00
|
||||
*/
|
||||
function alignTimestamp(
|
||||
timestamp: number,
|
||||
timeframe: DashboardChartTimeframe,
|
||||
): UTCTimestamp {
|
||||
const d = new Date(timestamp * 1000);
|
||||
|
||||
if (timeframe === "30m" || timeframe === "1h") {
|
||||
const bucket = timeframe === "30m" ? 30 : 60;
|
||||
d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0);
|
||||
} else if (timeframe === "1d") {
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
} else if (timeframe === "1w") {
|
||||
const day = d.getUTCDay();
|
||||
d.setUTCDate(d.getUTCDate() + (day === 0 ? -6 : 1 - day));
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
return Math.floor(d.getTime() / 1000) as UTCTimestamp;
|
||||
}
|
||||
|
||||
// ─── 봉 병합 ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 두 ChartBar 배열을 시간 기준으로 병합. 같은 시간대는 OHLCV 통합
|
||||
*/
|
||||
export function mergeBars(left: ChartBar[], right: ChartBar[]): ChartBar[] {
|
||||
const map = new Map<number, ChartBar>();
|
||||
for (const bar of [...left, ...right]) {
|
||||
const prev = map.get(bar.time);
|
||||
if (!prev) {
|
||||
map.set(bar.time, bar);
|
||||
continue;
|
||||
}
|
||||
map.set(bar.time, {
|
||||
time: bar.time,
|
||||
open: prev.open,
|
||||
high: Math.max(prev.high, bar.high),
|
||||
low: Math.min(prev.low, bar.low),
|
||||
close: bar.close,
|
||||
volume: Math.max(prev.volume, bar.volume),
|
||||
});
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 봉 업데이트: 같은 시간이면 기존 봉에 병합, 새 시간이면 추가
|
||||
*/
|
||||
export function upsertRealtimeBar(
|
||||
prev: ChartBar[],
|
||||
incoming: ChartBar,
|
||||
): ChartBar[] {
|
||||
if (prev.length === 0) return [incoming];
|
||||
const last = prev[prev.length - 1];
|
||||
|
||||
if (incoming.time > last.time) return [...prev, incoming];
|
||||
if (incoming.time < last.time) return prev;
|
||||
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
time: last.time,
|
||||
open: last.open,
|
||||
high: Math.max(last.high, incoming.high),
|
||||
low: Math.min(last.low, incoming.low),
|
||||
close: incoming.close,
|
||||
volume: Math.max(last.volume, incoming.volume),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 체결 틱을 차트용 ChartBar로 변환합니다. (KST 날짜 + tickTime 기준)
|
||||
* @see features/dashboard/hooks/useKisTradeWebSocket.ts latestTick
|
||||
* @see features/dashboard/components/chart/StockLineChart.tsx 실시간 캔들 반영
|
||||
*/
|
||||
export function toRealtimeTickBar(
|
||||
tick: DashboardRealtimeTradeTick,
|
||||
timeframe: DashboardChartTimeframe,
|
||||
now = new Date(),
|
||||
): ChartBar | null {
|
||||
if (!Number.isFinite(tick.price) || tick.price <= 0) return null;
|
||||
|
||||
const hhmmss = normalizeTickTime(tick.tickTime);
|
||||
if (!hhmmss) return null;
|
||||
|
||||
const ymd = getKstYmd(now);
|
||||
const baseTimestamp = toKstTimestamp(ymd, hhmmss);
|
||||
const alignedTimestamp = alignTimestamp(baseTimestamp, timeframe);
|
||||
const minuteFrame = isMinuteTimeframe(timeframe);
|
||||
|
||||
return {
|
||||
time: alignedTimestamp,
|
||||
open: minuteFrame ? tick.price : Math.max(tick.open, tick.price),
|
||||
high: minuteFrame ? tick.price : Math.max(tick.high, tick.price),
|
||||
low: minuteFrame ? tick.price : Math.min(tick.low || tick.price, tick.price),
|
||||
close: tick.price,
|
||||
volume: minuteFrame
|
||||
? Math.max(tick.tradeVolume, 0)
|
||||
: Math.max(tick.accumulatedVolume, 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description lightweight-charts X축 라벨을 KST 기준으로 강제 포맷합니다.
|
||||
* @see features/dashboard/components/chart/StockLineChart.tsx createChart options.timeScale.tickMarkFormatter
|
||||
*/
|
||||
export function formatKstTickMark(time: Time, tickMarkType: TickMarkType) {
|
||||
const date = toDateFromChartTime(time);
|
||||
if (!date) return null;
|
||||
|
||||
if (tickMarkType === 0) return KST_YEAR_FORMATTER.format(date);
|
||||
if (tickMarkType === 1) return KST_MONTH_FORMATTER.format(date);
|
||||
if (tickMarkType === 2) return KST_DATE_FORMATTER.format(date);
|
||||
if (tickMarkType === 4) return KST_TIME_SECONDS_FORMATTER.format(date);
|
||||
return KST_TIME_FORMATTER.format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description crosshair 시간 라벨을 KST로 포맷합니다.
|
||||
* @see features/dashboard/components/chart/StockLineChart.tsx createChart options.localization.timeFormatter
|
||||
*/
|
||||
export function formatKstCrosshairTime(time: Time) {
|
||||
const date = toDateFromChartTime(time);
|
||||
if (!date) return "";
|
||||
return KST_CROSSHAIR_FORMATTER.format(date);
|
||||
}
|
||||
|
||||
// ─── 포맷터 ───────────────────────────────────────────────
|
||||
|
||||
export function formatPrice(value: number) {
|
||||
return KRW_FORMATTER.format(Math.round(value));
|
||||
}
|
||||
|
||||
export function formatSignedPercent(value: number) {
|
||||
const sign = value > 0 ? "+" : "";
|
||||
return `${sign}${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분봉 타임프레임인지 판별
|
||||
*/
|
||||
export function isMinuteTimeframe(tf: DashboardChartTimeframe) {
|
||||
return tf === "1m" || tf === "30m" || tf === "1h";
|
||||
}
|
||||
|
||||
function normalizeTickTime(value?: string) {
|
||||
if (!value) return null;
|
||||
const normalized = value.trim();
|
||||
return /^\d{6}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function getKstYmd(now = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(now);
|
||||
|
||||
const map = new Map(parts.map((part) => [part.type, part.value]));
|
||||
return `${map.get("year")}${map.get("month")}${map.get("day")}`;
|
||||
}
|
||||
|
||||
function toKstTimestamp(yyyymmdd: string, hhmmss: string) {
|
||||
const y = Number(yyyymmdd.slice(0, 4));
|
||||
const m = Number(yyyymmdd.slice(4, 6));
|
||||
const d = Number(yyyymmdd.slice(6, 8));
|
||||
const hh = Number(hhmmss.slice(0, 2));
|
||||
const mm = Number(hhmmss.slice(2, 4));
|
||||
const ss = Number(hhmmss.slice(4, 6));
|
||||
return Math.floor(Date.UTC(y, m - 1, d, hh - 9, mm, ss) / 1000);
|
||||
}
|
||||
|
||||
function toDateFromChartTime(time: Time) {
|
||||
if (typeof time === "number" && Number.isFinite(time)) {
|
||||
return new Date(time * 1000);
|
||||
}
|
||||
|
||||
if (typeof time === "string") {
|
||||
const parsed = Date.parse(time);
|
||||
return Number.isFinite(parsed) ? new Date(parsed) : null;
|
||||
}
|
||||
|
||||
if (time && typeof time === "object" && "year" in time) {
|
||||
const { year, month, day } = time;
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { Activity, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { StockLineChart } from "@/features/dashboard/components/chart/StockLineChart";
|
||||
import { StockPriceBadge } from "@/features/dashboard/components/details/StockPriceBadge";
|
||||
import type {
|
||||
DashboardStockItem,
|
||||
DashboardPriceSource,
|
||||
DashboardMarketPhase,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
|
||||
function formatVolume(value: number) {
|
||||
return `${PRICE_FORMATTER.format(value)}주`;
|
||||
}
|
||||
|
||||
function getPriceSourceLabel(
|
||||
source: DashboardPriceSource,
|
||||
marketPhase: DashboardMarketPhase,
|
||||
) {
|
||||
switch (source) {
|
||||
case "inquire-overtime-price":
|
||||
return "시간외 현재가(inquire-overtime-price)";
|
||||
case "inquire-ccnl":
|
||||
return marketPhase === "afterHours"
|
||||
? "체결가 폴백(inquire-ccnl)"
|
||||
: "체결가(inquire-ccnl)";
|
||||
default:
|
||||
return "현재가(inquire-price)";
|
||||
}
|
||||
}
|
||||
|
||||
function PriceStat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StockOverviewCardProps {
|
||||
stock: DashboardStockItem;
|
||||
priceSource: DashboardPriceSource;
|
||||
marketPhase: DashboardMarketPhase;
|
||||
isRealtimeConnected: boolean;
|
||||
realtimeTrId: string | null;
|
||||
lastRealtimeTickAt: number | null;
|
||||
}
|
||||
|
||||
export function StockOverviewCard({
|
||||
stock,
|
||||
priceSource,
|
||||
marketPhase,
|
||||
isRealtimeConnected,
|
||||
realtimeTrId,
|
||||
lastRealtimeTickAt,
|
||||
}: StockOverviewCardProps) {
|
||||
const apiPriceSourceLabel = getPriceSourceLabel(priceSource, marketPhase);
|
||||
const effectivePriceSourceLabel =
|
||||
isRealtimeConnected && lastRealtimeTickAt
|
||||
? `실시간 체결(WebSocket ${realtimeTrId || ""})`
|
||||
: apiPriceSourceLabel;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-brand-200">
|
||||
<CardHeader className="border-b border-border/50 bg-muted/30 pb-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-xl font-bold">{stock.name}</CardTitle>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{stock.symbol}
|
||||
</span>
|
||||
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700">
|
||||
{stock.market}
|
||||
</span>
|
||||
</div>
|
||||
<CardDescription className="mt-1 flex items-center gap-1.5">
|
||||
<span>{effectivePriceSourceLabel}</span>
|
||||
{isRealtimeConnected && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-brand-100 px-1.5 py-0.5 text-xs font-medium text-brand-700">
|
||||
<Activity className="h-3 w-3" />
|
||||
실시간
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<StockPriceBadge
|
||||
currentPrice={stock.currentPrice}
|
||||
change={stock.change}
|
||||
changeRate={stock.changeRate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="grid border-b border-border/50 lg:grid-cols-3">
|
||||
<div className="col-span-2 border-r border-border/50">
|
||||
{/* Chart Area */}
|
||||
<div className="p-6">
|
||||
<StockLineChart candles={stock.candles} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 bg-muted/10 p-6">
|
||||
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-foreground/80">
|
||||
<ShieldCheck className="h-4 w-4 text-brand-600" />
|
||||
주요 시세 정보
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<PriceStat
|
||||
label="시가"
|
||||
value={`${PRICE_FORMATTER.format(stock.open)}원`}
|
||||
/>
|
||||
<PriceStat
|
||||
label="고가"
|
||||
value={`${PRICE_FORMATTER.format(stock.high)}원`}
|
||||
/>
|
||||
<PriceStat
|
||||
label="저가"
|
||||
value={`${PRICE_FORMATTER.format(stock.low)}원`}
|
||||
/>
|
||||
<PriceStat
|
||||
label="전일종가"
|
||||
value={`${PRICE_FORMATTER.format(stock.prevClose)}원`}
|
||||
/>
|
||||
<div className="col-span-2">
|
||||
<PriceStat label="거래량" value={formatVolume(stock.volume)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
|
||||
function formatPrice(value: number) {
|
||||
return `${PRICE_FORMATTER.format(value)}원`;
|
||||
}
|
||||
|
||||
interface StockPriceBadgeProps {
|
||||
currentPrice: number;
|
||||
change: number;
|
||||
changeRate: number;
|
||||
}
|
||||
|
||||
export function StockPriceBadge({
|
||||
currentPrice,
|
||||
change,
|
||||
changeRate,
|
||||
}: StockPriceBadgeProps) {
|
||||
const isPositive = change >= 0;
|
||||
const ChangeIcon = isPositive ? TrendingUp : TrendingDown;
|
||||
const changeColor = isPositive ? "text-red-500" : "text-brand-600";
|
||||
const changeSign = isPositive ? "+" : "";
|
||||
|
||||
return (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={cn("text-3xl font-bold", changeColor)}>
|
||||
{formatPrice(currentPrice)}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-sm font-medium",
|
||||
changeColor,
|
||||
)}
|
||||
>
|
||||
<ChangeIcon className="h-4 w-4" />
|
||||
<span>
|
||||
{changeSign}
|
||||
{PRICE_FORMATTER.format(change)}원
|
||||
</span>
|
||||
<span>
|
||||
({changeSign}
|
||||
{changeRate.toFixed(2)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DashboardStockItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StockHeaderProps {
|
||||
stock: DashboardStockItem;
|
||||
price: string;
|
||||
change: string;
|
||||
changeRate: string;
|
||||
high?: string;
|
||||
low?: string;
|
||||
volume?: string;
|
||||
}
|
||||
|
||||
export function StockHeader({
|
||||
stock,
|
||||
price,
|
||||
change,
|
||||
changeRate,
|
||||
high,
|
||||
low,
|
||||
volume,
|
||||
}: StockHeaderProps) {
|
||||
const isRise = changeRate.startsWith("+") || parseFloat(changeRate) > 0;
|
||||
const isFall = changeRate.startsWith("-") || parseFloat(changeRate) < 0;
|
||||
const colorClass = isRise
|
||||
? "text-red-500"
|
||||
: isFall
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-foreground";
|
||||
|
||||
return (
|
||||
<div className="bg-white px-3 py-2 dark:bg-brand-900/22 sm:px-4 sm:py-3">
|
||||
{/* ========== STOCK SUMMARY ========== */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-lg font-bold leading-tight text-foreground dark:text-brand-50 sm:text-xl">
|
||||
{stock.name}
|
||||
</h1>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground dark:text-brand-100/70 sm:text-sm">
|
||||
{stock.symbol}/{stock.market}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={cn("shrink-0 text-right", colorClass)}>
|
||||
<span className="block text-2xl font-bold tracking-tight">{price}</span>
|
||||
<span className="text-xs font-medium sm:text-sm">
|
||||
{changeRate}% <span className="ml-1 text-[11px] sm:text-xs">{change}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== STATS ========== */}
|
||||
<div className="mt-2 grid grid-cols-3 gap-2 text-xs md:hidden">
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
|
||||
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">고가</p>
|
||||
<p className="font-medium text-red-500">{high || "--"}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
|
||||
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">저가</p>
|
||||
<p className="font-medium text-blue-600 dark:text-blue-400">{low || "--"}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
|
||||
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">거래량(24H)</p>
|
||||
<p className="font-medium">{volume || "--"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="mt-2 md:hidden" />
|
||||
|
||||
{/* ========== DESKTOP STATS ========== */}
|
||||
<div className="hidden items-center justify-end gap-6 pt-1 text-sm md:flex">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-muted-foreground text-xs dark:text-brand-100/70">고가</span>
|
||||
<span className="font-medium text-red-500">{high || "--"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-muted-foreground text-xs dark:text-brand-100/70">저가</span>
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">{low || "--"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-muted-foreground text-xs dark:text-brand-100/70">거래량(24H)</span>
|
||||
<span className="font-medium">{volume || "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
header: ReactNode;
|
||||
chart: ReactNode;
|
||||
orderBook: ReactNode;
|
||||
orderForm: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DashboardLayout({
|
||||
header,
|
||||
chart,
|
||||
orderBook,
|
||||
orderForm,
|
||||
className,
|
||||
}: DashboardLayoutProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col bg-background dark:bg-[linear-gradient(135deg,rgba(53,35,86,0.18),rgba(13,13,20,0.98))]",
|
||||
// Mobile: Scrollable page height
|
||||
"min-h-[calc(100vh-64px)]",
|
||||
// Desktop: Fixed height, no window scroll
|
||||
"xl:h-[calc(100vh-64px)] xl:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 1. Header Area */}
|
||||
<div className="flex-none border-b border-brand-100 bg-white dark:border-brand-800/45 dark:bg-brand-900/22">
|
||||
{header}
|
||||
</div>
|
||||
|
||||
{/* 2. Main Content Area */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 flex-col",
|
||||
// Mobile: Allow content to flow naturally with spacing
|
||||
"overflow-visible pb-4 gap-4",
|
||||
// Desktop: Internal scrolling, horizontal layout, no page spacing
|
||||
"xl:overflow-hidden xl:flex-row xl:pb-0 xl:gap-0",
|
||||
)}
|
||||
>
|
||||
{/* Left Column: Chart & Info */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col border-border dark:border-brand-800/45",
|
||||
// Mobile: Fixed height for chart to ensure visibility
|
||||
"h-[320px] flex-none border-b sm:h-[360px]",
|
||||
// Desktop: Fill remaining space, remove bottom border, add right border
|
||||
"xl:flex-1 xl:h-auto xl:min-h-0 xl:min-w-0 xl:border-b-0 xl:border-r",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-h-0">{chart}</div>
|
||||
{/* Future: Transaction History / Market Depth can go here */}
|
||||
</div>
|
||||
|
||||
{/* Right Column: Order Book & Order Form */}
|
||||
<div className="flex min-h-0 w-full flex-none flex-col bg-background dark:bg-brand-900/12 xl:w-[460px] xl:pr-2 2xl:w-[500px]">
|
||||
{/* Top: Order Book (Hoga) */}
|
||||
<div className="h-[390px] flex-none overflow-hidden border-t border-border dark:border-brand-800/45 sm:h-[430px] xl:min-h-0 xl:flex-1 xl:h-auto xl:border-t-0 xl:border-b">
|
||||
{orderBook}
|
||||
</div>
|
||||
{/* Bottom: Order Form */}
|
||||
<div className="flex-none h-auto sm:h-auto xl:h-[380px]">
|
||||
{orderForm}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useOrder } from "@/features/dashboard/hooks/useOrder";
|
||||
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardOrderSide,
|
||||
DashboardStockItem,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
interface OrderFormProps {
|
||||
stock?: DashboardStockItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 대시보드 주문 패널에서 매수/매도 주문을 입력하고 전송합니다.
|
||||
* @see features/dashboard/hooks/useOrder.ts placeOrder - 주문 API 호출
|
||||
* @see features/dashboard/components/DashboardContainer.tsx OrderForm - 우측 주문 패널 렌더링
|
||||
*/
|
||||
export function OrderForm({ stock }: OrderFormProps) {
|
||||
const verifiedCredentials = useKisRuntimeStore(
|
||||
(state) => state.verifiedCredentials,
|
||||
);
|
||||
const { placeOrder, isLoading, error } = useOrder();
|
||||
|
||||
// ========== FORM STATE ==========
|
||||
const [price, setPrice] = useState<string>(stock?.currentPrice.toString() || "");
|
||||
const [quantity, setQuantity] = useState<string>("");
|
||||
const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy");
|
||||
|
||||
// ========== ORDER HANDLER ==========
|
||||
const handleOrder = async (side: DashboardOrderSide) => {
|
||||
if (!stock || !verifiedCredentials) return;
|
||||
|
||||
const priceNum = parseInt(price.replace(/,/g, ""), 10);
|
||||
const qtyNum = parseInt(quantity.replace(/,/g, ""), 10);
|
||||
|
||||
if (Number.isNaN(priceNum) || priceNum <= 0) {
|
||||
alert("가격을 올바르게 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(qtyNum) || qtyNum <= 0) {
|
||||
alert("수량을 올바르게 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
if (!verifiedCredentials.accountNo) {
|
||||
alert("계좌번호가 없습니다. 설정에서 계좌번호를 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await placeOrder(
|
||||
{
|
||||
symbol: stock.symbol,
|
||||
side,
|
||||
orderType: "limit",
|
||||
price: priceNum,
|
||||
quantity: qtyNum,
|
||||
accountNo: verifiedCredentials.accountNo,
|
||||
accountProductCode: "01",
|
||||
},
|
||||
verifiedCredentials,
|
||||
);
|
||||
|
||||
if (response?.orderNo) {
|
||||
alert(`주문 전송 완료: ${response.orderNo}`);
|
||||
setQuantity("");
|
||||
}
|
||||
};
|
||||
|
||||
const totalPrice =
|
||||
parseInt(price.replace(/,/g, "") || "0", 10) *
|
||||
parseInt(quantity.replace(/,/g, "") || "0", 10);
|
||||
|
||||
const setPercent = (pct: string) => {
|
||||
// TODO: 계좌 잔고 연동 시 퍼센트 자동 계산으로 교체
|
||||
console.log("Percent clicked:", pct);
|
||||
};
|
||||
|
||||
const isMarketDataAvailable = Boolean(stock);
|
||||
|
||||
return (
|
||||
<div className="h-full border-l border-border bg-background p-3 dark:border-brand-800/45 dark:bg-brand-950/55 sm:p-4">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as "buy" | "sell")}
|
||||
className="flex h-full w-full flex-col"
|
||||
>
|
||||
{/* ========== ORDER SIDE TABS ========== */}
|
||||
<TabsList className="mb-3 grid h-10 w-full grid-cols-2 gap-1 border border-brand-200/70 bg-muted/35 p-1 dark:border-brand-700/50 dark:bg-brand-900/28 sm:mb-4">
|
||||
<TabsTrigger
|
||||
value="buy"
|
||||
className="!h-full rounded-md border border-transparent text-foreground/75 transition-colors dark:text-brand-100/75 data-[state=active]:border-red-400/60 data-[state=active]:bg-red-600 data-[state=active]:text-white data-[state=active]:shadow-[0_0_0_1px_rgba(248,113,113,0.45)]"
|
||||
>
|
||||
매수
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sell"
|
||||
className="!h-full rounded-md border border-transparent text-foreground/75 transition-colors dark:text-brand-100/75 data-[state=active]:border-blue-400/65 data-[state=active]:bg-blue-600 data-[state=active]:text-white data-[state=active]:shadow-[0_0_0_1px_rgba(96,165,250,0.45)]"
|
||||
>
|
||||
매도
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ========== BUY TAB ========== */}
|
||||
<TabsContent
|
||||
value="buy"
|
||||
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
|
||||
>
|
||||
<OrderInputs
|
||||
type="buy"
|
||||
price={price}
|
||||
setPrice={setPrice}
|
||||
quantity={quantity}
|
||||
setQuantity={setQuantity}
|
||||
totalPrice={totalPrice}
|
||||
disabled={!isMarketDataAvailable}
|
||||
hasError={Boolean(error)}
|
||||
errorMessage={error}
|
||||
/>
|
||||
<PercentButtons onSelect={setPercent} />
|
||||
<Button
|
||||
className="mt-auto h-11 w-full bg-red-600 text-base text-white shadow-sm ring-1 ring-red-300/35 hover:bg-red-700 dark:bg-red-500 dark:ring-red-300/45 dark:hover:bg-red-400 sm:h-12 sm:text-lg"
|
||||
disabled={isLoading || !isMarketDataAvailable}
|
||||
onClick={() => handleOrder("buy")}
|
||||
>
|
||||
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매수하기"}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
{/* ========== SELL TAB ========== */}
|
||||
<TabsContent
|
||||
value="sell"
|
||||
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
|
||||
>
|
||||
<OrderInputs
|
||||
type="sell"
|
||||
price={price}
|
||||
setPrice={setPrice}
|
||||
quantity={quantity}
|
||||
setQuantity={setQuantity}
|
||||
totalPrice={totalPrice}
|
||||
disabled={!isMarketDataAvailable}
|
||||
hasError={Boolean(error)}
|
||||
errorMessage={error}
|
||||
/>
|
||||
<PercentButtons onSelect={setPercent} />
|
||||
<Button
|
||||
className="mt-auto h-11 w-full bg-blue-600 text-base text-white shadow-sm ring-1 ring-blue-300/35 hover:bg-blue-700 dark:bg-blue-500 dark:ring-blue-300/45 dark:hover:bg-blue-400 sm:h-12 sm:text-lg"
|
||||
disabled={isLoading || !isMarketDataAvailable}
|
||||
onClick={() => handleOrder("sell")}
|
||||
>
|
||||
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매도하기"}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 주문 입력 영역(가격/수량/총액)을 렌더링합니다.
|
||||
* @see features/dashboard/components/order/OrderForm.tsx OrderForm - 매수/매도 탭에서 공용 호출
|
||||
*/
|
||||
function OrderInputs({
|
||||
type,
|
||||
price,
|
||||
setPrice,
|
||||
quantity,
|
||||
setQuantity,
|
||||
totalPrice,
|
||||
disabled,
|
||||
hasError,
|
||||
errorMessage,
|
||||
}: {
|
||||
type: "buy" | "sell";
|
||||
price: string;
|
||||
setPrice: (v: string) => void;
|
||||
quantity: string;
|
||||
setQuantity: (v: string) => void;
|
||||
totalPrice: number;
|
||||
disabled: boolean;
|
||||
hasError: boolean;
|
||||
errorMessage: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>주문가능</span>
|
||||
<span>- {type === "buy" ? "KRW" : "주"}</span>
|
||||
</div>
|
||||
|
||||
{hasError && (
|
||||
<div className="rounded bg-destructive/10 p-2 text-xs text-destructive break-keep">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-2">
|
||||
<span className="text-xs font-medium sm:text-sm">
|
||||
{type === "buy" ? "매수가격" : "매도가격"}
|
||||
</span>
|
||||
<Input
|
||||
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
|
||||
placeholder="0"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-2">
|
||||
<span className="text-xs font-medium sm:text-sm">주문수량</span>
|
||||
<Input
|
||||
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
|
||||
placeholder="0"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-2">
|
||||
<span className="text-xs font-medium sm:text-sm">주문총액</span>
|
||||
<Input
|
||||
className="col-span-3 bg-muted/50 text-right font-mono dark:border-brand-700/55 dark:bg-black/20 dark:text-brand-100"
|
||||
value={totalPrice.toLocaleString()}
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 주문 비율(10/25/50/100%) 단축 버튼을 표시합니다.
|
||||
* @see features/dashboard/components/order/OrderForm.tsx setPercent - 버튼 클릭 이벤트 처리
|
||||
*/
|
||||
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-4 gap-2">
|
||||
{["10%", "25%", "50%", "100%"].map((pct) => (
|
||||
<Button
|
||||
key={pct}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => onSelect(pct)}
|
||||
>
|
||||
{pct}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
interface AnimatedQuantityProps {
|
||||
value: number;
|
||||
format?: (val: number) => string;
|
||||
className?: string;
|
||||
/** 값 변동 시 배경 깜빡임 */
|
||||
useColor?: boolean;
|
||||
/** 정렬 방향 (ask: 우측 정렬/왼쪽으로 확장, bid: 좌측 정렬/오른쪽으로 확장) */
|
||||
side?: "ask" | "bid";
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 수량 표시 — 값이 변할 때 ±diff를 인라인으로 보여줍니다.
|
||||
*/
|
||||
export function AnimatedQuantity({
|
||||
value,
|
||||
format = (v) => v.toLocaleString(),
|
||||
className,
|
||||
useColor = false,
|
||||
side = "bid",
|
||||
}: AnimatedQuantityProps) {
|
||||
const prevRef = useRef(value);
|
||||
const [diff, setDiff] = useState<number | null>(null);
|
||||
const [flash, setFlash] = useState<"up" | "down" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevRef.current === value) return;
|
||||
|
||||
const delta = value - prevRef.current;
|
||||
prevRef.current = value;
|
||||
|
||||
if (delta === 0) return;
|
||||
|
||||
setDiff(delta);
|
||||
setFlash(delta > 0 ? "up" : "down");
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setDiff(null);
|
||||
setFlash(null);
|
||||
}, 1200);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex items-center gap-1 tabular-nums",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 배경 깜빡임 */}
|
||||
<AnimatePresence>
|
||||
{useColor && flash && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0.5 }}
|
||||
animate={{ opacity: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className={cn(
|
||||
"absolute inset-0 z-0 rounded-sm",
|
||||
flash === "up" ? "bg-red-200/50" : "bg-blue-200/50",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 매도(Ask)일 경우 Diff가 먼저 와야 텍스트가 우측 정렬된 상태에서 흔들리지 않음 */}
|
||||
{side === "ask" && <DiffChange diff={diff} />}
|
||||
|
||||
{/* 수량 값 */}
|
||||
<span className="relative z-10">{format(value)}</span>
|
||||
|
||||
{/* 매수(Bid)일 경우 Diff가 뒤에 와야 텍스트가 좌측 정렬된 상태에서 흔들리지 않음 */}
|
||||
{side !== "ask" && <DiffChange diff={diff} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffChange({ diff }: { diff: number | null }) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{diff != null && diff !== 0 && (
|
||||
<motion.span
|
||||
initial={{ opacity: 1, scale: 1 }}
|
||||
animate={{ opacity: 0, scale: 0.85 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1.2, ease: "easeOut" }}
|
||||
className={cn(
|
||||
"relative z-20 whitespace-nowrap text-[9px] font-bold leading-none tabular-nums",
|
||||
diff > 0 ? "text-red-500" : "text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
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/dashboard/types/dashboard.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnimatedQuantity } from "./AnimatedQuantity";
|
||||
|
||||
// ─── 타입 ───────────────────────────────────────────────
|
||||
|
||||
interface OrderBookProps {
|
||||
symbol?: string;
|
||||
referencePrice?: number;
|
||||
currentPrice?: number;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
recentTicks: DashboardRealtimeTradeTick[];
|
||||
orderBook: DashboardStockOrderBookResponse | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface BookRow {
|
||||
price: number;
|
||||
size: number;
|
||||
changePercent: number | null;
|
||||
isHighlighted: boolean;
|
||||
}
|
||||
|
||||
// ─── 유틸리티 함수 ──────────────────────────────────────
|
||||
|
||||
/** 천단위 구분 포맷 */
|
||||
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;
|
||||
}
|
||||
|
||||
/** 체결 시각 포맷 */
|
||||
function fmtTime(hms: string) {
|
||||
if (!hms || hms.length !== 6) return "--:--:--";
|
||||
return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`;
|
||||
}
|
||||
|
||||
// ─── 메인 컴포넌트 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다.
|
||||
*/
|
||||
export function OrderBook({
|
||||
symbol,
|
||||
referencePrice,
|
||||
currentPrice,
|
||||
latestTick,
|
||||
recentTicks,
|
||||
orderBook,
|
||||
isLoading,
|
||||
}: OrderBookProps) {
|
||||
const levels = useMemo(() => orderBook?.levels ?? [], [orderBook]);
|
||||
|
||||
// 체결가: tick에서 우선, 없으면 0
|
||||
const latestPrice =
|
||||
latestTick?.price && latestTick.price > 0 ? latestTick.price : 0;
|
||||
|
||||
// 등락률 기준가
|
||||
const basePrice =
|
||||
(referencePrice ?? 0) > 0
|
||||
? referencePrice!
|
||||
: (currentPrice ?? 0) > 0
|
||||
? currentPrice!
|
||||
: latestPrice > 0
|
||||
? latestPrice
|
||||
: 0;
|
||||
|
||||
// 매도호가 (역순: 10호가 → 1호가)
|
||||
const askRows: BookRow[] = useMemo(
|
||||
() =>
|
||||
[...levels].reverse().map((l) => ({
|
||||
price: l.askPrice,
|
||||
size: Math.max(l.askSize, 0),
|
||||
changePercent:
|
||||
l.askPrice > 0 && basePrice > 0
|
||||
? pctChange(l.askPrice, basePrice)
|
||||
: null,
|
||||
isHighlighted: latestPrice > 0 && l.askPrice === latestPrice,
|
||||
})),
|
||||
[levels, basePrice, latestPrice],
|
||||
);
|
||||
|
||||
// 매수호가 (1호가 → 10호가)
|
||||
const bidRows: BookRow[] = useMemo(
|
||||
() =>
|
||||
levels.map((l) => ({
|
||||
price: l.bidPrice,
|
||||
size: Math.max(l.bidSize, 0),
|
||||
changePercent:
|
||||
l.bidPrice > 0 && basePrice > 0
|
||||
? pctChange(l.bidPrice, basePrice)
|
||||
: null,
|
||||
isHighlighted: latestPrice > 0 && l.bidPrice === latestPrice,
|
||||
})),
|
||||
[levels, basePrice, latestPrice],
|
||||
);
|
||||
|
||||
const askMax = Math.max(1, ...askRows.map((r) => r.size));
|
||||
const bidMax = Math.max(1, ...bidRows.map((r) => r.size));
|
||||
|
||||
// 스프레드·수급 불균형
|
||||
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 ?? 0;
|
||||
const totalBid = orderBook?.totalBidSize ?? 0;
|
||||
const imbalance =
|
||||
totalAsk + totalBid > 0
|
||||
? ((totalBid - totalAsk) / (totalAsk + totalBid)) * 100
|
||||
: 0;
|
||||
|
||||
// 체결가 행 중앙 스크롤
|
||||
|
||||
// ─── 빈/로딩 상태 ───
|
||||
if (!symbol) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
종목을 선택해주세요.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isLoading && !orderBook) return <OrderBookSkeleton />;
|
||||
if (!orderBook) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
호가 정보를 가져오지 못했습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-brand-900/10">
|
||||
<Tabs defaultValue="normal" className="h-full min-h-0">
|
||||
{/* 탭 헤더 */}
|
||||
<div className="border-b px-2 pt-2 dark:border-brand-800/45 dark:bg-brand-900/28">
|
||||
<TabsList variant="line" className="w-full justify-start">
|
||||
<TabsTrigger value="normal" className="px-3">
|
||||
일반호가
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cumulative" className="px-3">
|
||||
누적호가
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="order" className="px-3">
|
||||
호가주문
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* ── 일반호가 탭 ── */}
|
||||
<TabsContent value="normal" className="min-h-0 flex-1">
|
||||
<div className="block h-full min-h-0 border-t dark:border-brand-800/45 xl:grid xl:grid-rows-[1fr_190px] xl:overflow-hidden">
|
||||
<div className="block min-h-0 xl:grid xl:grid-cols-[minmax(0,1fr)_168px] xl:overflow-hidden">
|
||||
{/* 호가 테이블 */}
|
||||
<div className="min-h-0 xl:border-r dark:border-brand-800/45">
|
||||
<BookHeader />
|
||||
<ScrollArea className="h-[320px] sm:h-[360px] xl:h-[calc(100%-32px)] [&>[data-slot=scroll-area-scrollbar]]:hidden xl:[&>[data-slot=scroll-area-scrollbar]]:flex">
|
||||
{/* 매도호가 */}
|
||||
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
||||
|
||||
{/* 중앙 바: 현재 체결가 */}
|
||||
<div className="grid h-8 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30 xl:h-9">
|
||||
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
|
||||
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="text-xs font-bold tabular-nums">
|
||||
{latestPrice > 0
|
||||
? fmt(latestPrice)
|
||||
: bestAsk > 0
|
||||
? fmt(bestAsk)
|
||||
: "-"}
|
||||
</span>
|
||||
{latestPrice > 0 && basePrice > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-medium",
|
||||
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-medium text-muted-foreground dark:text-brand-100/72">
|
||||
{totalBid > 0 ? fmt(totalBid) : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매수호가 */}
|
||||
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 우측 요약 패널 */}
|
||||
<div className="hidden xl:block">
|
||||
<SummaryPanel
|
||||
orderBook={orderBook}
|
||||
latestTick={latestTick}
|
||||
spread={spread}
|
||||
imbalance={imbalance}
|
||||
totalAsk={totalAsk}
|
||||
totalBid={totalBid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 체결 목록 */}
|
||||
<div className="hidden xl:block">
|
||||
<TradeTape ticks={recentTicks} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── 누적호가 탭 ── */}
|
||||
<TabsContent value="cumulative" className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full border-t dark:border-brand-800/45">
|
||||
<div className="p-3">
|
||||
<div className="mb-2 grid grid-cols-3 text-[11px] font-medium text-muted-foreground">
|
||||
<span>매도누적</span>
|
||||
<span className="text-center">호가</span>
|
||||
<span className="text-right">매수누적</span>
|
||||
</div>
|
||||
<CumulativeRows asks={askRows} bids={bidRows} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── 호가주문 탭 ── */}
|
||||
<TabsContent value="order" className="min-h-0 flex-1">
|
||||
<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">
|
||||
호가주문 탭은 주문 입력 패널과 연동해 확장할 수 있습니다.
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 하위 컴포넌트 ──────────────────────────────────────
|
||||
|
||||
/** 호가 표 헤더 */
|
||||
function BookHeader() {
|
||||
return (
|
||||
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
|
||||
<div className="flex items-center justify-end px-2">매도잔량</div>
|
||||
<div className="flex items-center justify-center border-x">호가</div>
|
||||
<div className="flex items-center justify-start px-2">매수잔량</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 매도 또는 매수 호가 행 목록 */
|
||||
function BookSideRows({
|
||||
rows,
|
||||
side,
|
||||
maxSize,
|
||||
}: {
|
||||
rows: BookRow[];
|
||||
side: "ask" | "bid";
|
||||
maxSize: number;
|
||||
}) {
|
||||
const isAsk = side === "ask";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
isAsk
|
||||
? "bg-red-50/20 dark:bg-red-950/18"
|
||||
: "bg-blue-50/55 dark:bg-blue-950/22",
|
||||
)}
|
||||
>
|
||||
{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-7 grid-cols-3 border-b border-border/40 text-[11px] xl:h-8 xl:text-xs dark:border-brand-800/35",
|
||||
row.isHighlighted &&
|
||||
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-800/30",
|
||||
)}
|
||||
>
|
||||
{/* 매도잔량 (좌측) */}
|
||||
<div className="relative flex items-center justify-end overflow-hidden px-2">
|
||||
{isAsk && (
|
||||
<>
|
||||
<DepthBar ratio={ratio} side="ask" />
|
||||
<AnimatedQuantity
|
||||
value={row.size}
|
||||
format={fmt}
|
||||
useColor
|
||||
side="ask"
|
||||
className="relative z-10"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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={
|
||||
isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400"
|
||||
}
|
||||
>
|
||||
{row.price > 0 ? fmt(row.price) : "-"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
row.changePercent !== null
|
||||
? row.changePercent >= 0
|
||||
? "text-red-500"
|
||||
: "text-blue-600 dark:text-blue-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{row.changePercent === null ? "-" : fmtPct(row.changePercent)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 매수잔량 (우측) */}
|
||||
<div className="relative flex items-center justify-start overflow-hidden px-1">
|
||||
{!isAsk && (
|
||||
<>
|
||||
<DepthBar ratio={ratio} side="bid" />
|
||||
<AnimatedQuantity
|
||||
value={row.size}
|
||||
format={fmt}
|
||||
useColor
|
||||
side="bid"
|
||||
className="relative z-10"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 우측 요약 패널 */
|
||||
function SummaryPanel({
|
||||
orderBook,
|
||||
latestTick,
|
||||
spread,
|
||||
imbalance,
|
||||
totalAsk,
|
||||
totalBid,
|
||||
}: {
|
||||
orderBook: DashboardStockOrderBookResponse;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
spread: number;
|
||||
imbalance: number;
|
||||
totalAsk: number;
|
||||
totalBid: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 border-l bg-muted/10 p-2 text-[11px] dark:border-brand-800/45 dark:bg-brand-900/30">
|
||||
<Row
|
||||
label="실시간"
|
||||
value={orderBook ? "연결됨" : "끊김"}
|
||||
tone={orderBook ? "bid" : undefined}
|
||||
/>
|
||||
<Row
|
||||
label="거래량"
|
||||
value={fmt(latestTick?.tradeVolume ?? orderBook.anticipatedVolume ?? 0)}
|
||||
/>
|
||||
<Row
|
||||
label="누적거래량"
|
||||
value={fmt(
|
||||
latestTick?.accumulatedVolume ?? orderBook.accumulatedVolume ?? 0,
|
||||
)}
|
||||
/>
|
||||
<Row
|
||||
label="체결강도"
|
||||
value={
|
||||
latestTick
|
||||
? `${latestTick.tradeStrength.toFixed(2)}%`
|
||||
: orderBook.anticipatedChangeRate !== undefined
|
||||
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
|
||||
: "-"
|
||||
}
|
||||
/>
|
||||
<Row label="예상체결가" value={fmt(orderBook.anticipatedPrice ?? 0)} />
|
||||
<Row
|
||||
label="매도1호가"
|
||||
value={latestTick ? fmt(latestTick.askPrice1) : "-"}
|
||||
tone="ask"
|
||||
/>
|
||||
<Row
|
||||
label="매수1호가"
|
||||
value={latestTick ? fmt(latestTick.bidPrice1) : "-"}
|
||||
tone="bid"
|
||||
/>
|
||||
<Row
|
||||
label="매수체결"
|
||||
value={latestTick ? fmt(latestTick.buyExecutionCount) : "-"}
|
||||
/>
|
||||
<Row
|
||||
label="매도체결"
|
||||
value={latestTick ? fmt(latestTick.sellExecutionCount) : "-"}
|
||||
/>
|
||||
<Row
|
||||
label="순매수체결"
|
||||
value={latestTick ? fmt(latestTick.netBuyExecutionCount) : "-"}
|
||||
/>
|
||||
<Row label="총 매도잔량" value={fmt(totalAsk)} tone="ask" />
|
||||
<Row label="총 매수잔량" value={fmt(totalBid)} tone="bid" />
|
||||
<Row label="스프레드" value={fmt(spread)} />
|
||||
<Row
|
||||
label="수급 불균형"
|
||||
value={`${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`}
|
||||
tone={imbalance >= 0 ? "bid" : "ask"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 요약 패널 단일 행 */
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "ask" | "bid";
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
|
||||
<span className="min-w-0 truncate text-muted-foreground dark:text-brand-100/70">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 font-medium tabular-nums",
|
||||
tone === "ask" && "text-red-600",
|
||||
tone === "bid" && "text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 잔량 깊이 바 */
|
||||
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
||||
if (ratio <= 0) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-1 z-0 rounded-sm",
|
||||
side === "ask"
|
||||
? "right-1 bg-red-200/50 dark:bg-red-800/40"
|
||||
: "left-1 bg-blue-200/55 dark:bg-blue-500/35",
|
||||
)}
|
||||
style={{ width: `${ratio}%` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 체결 목록 (Trade Tape) */
|
||||
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
||||
return (
|
||||
<div className="border-t bg-background dark:border-brand-800/45 dark:bg-brand-900/20">
|
||||
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
|
||||
<div className="flex items-center">체결시각</div>
|
||||
<div className="flex items-center justify-end">체결가</div>
|
||||
<div className="flex items-center justify-end">체결량</div>
|
||||
<div className="flex items-center justify-end">체결강도</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[162px]">
|
||||
<div>
|
||||
{ticks.length === 0 && (
|
||||
<div className="flex h-[160px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
|
||||
체결 데이터가 아직 없습니다.
|
||||
</div>
|
||||
)}
|
||||
{ticks.map((t, i) => (
|
||||
<div
|
||||
key={`${t.tickTime}-${t.price}-${i}`}
|
||||
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs dark:border-brand-800/35"
|
||||
>
|
||||
<div className="flex items-center tabular-nums">
|
||||
{fmtTime(t.tickTime)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums text-red-600">
|
||||
{fmt(t.price)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums text-blue-600 dark:text-blue-400">
|
||||
{fmt(t.tradeVolume)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums">
|
||||
{t.tradeStrength.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</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-red-600">{fmt(r.askAcc)}</span>
|
||||
<span className="text-center font-medium tabular-nums">
|
||||
{fmt(r.price)}
|
||||
</span>
|
||||
<span className="text-right tabular-nums text-blue-600 dark:text-blue-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>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface StockSearchFormProps {
|
||||
keyword: string;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onInputFocus?: () => void;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 종목 검색 입력/제출 폼을 렌더링합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 검색 패널에서 키워드 입력 이벤트를 전달합니다.
|
||||
*/
|
||||
export function StockSearchForm({
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
onSubmit,
|
||||
onInputFocus,
|
||||
disabled,
|
||||
isLoading,
|
||||
}: StockSearchFormProps) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="flex gap-2">
|
||||
{/* ========== SEARCH INPUT ========== */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground dark:text-brand-100/65" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => onKeywordChange(e.target.value)}
|
||||
onFocus={onInputFocus}
|
||||
placeholder="종목명 또는 종목코드(6자리)를 입력하세요."
|
||||
autoComplete="off"
|
||||
className="pl-9 dark:border-brand-700/55 dark:bg-brand-900/28 dark:text-brand-100 dark:placeholder:text-brand-100/55"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ========== SUBMIT BUTTON ========== */}
|
||||
<Button type="submit" disabled={disabled || isLoading}>
|
||||
{isLoading ? "검색 중..." : "검색"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Clock3, Trash2, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DashboardStockSearchHistoryItem } from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
interface StockSearchHistoryProps {
|
||||
items: DashboardStockSearchHistoryItem[];
|
||||
selectedSymbol?: string;
|
||||
onSelect: (item: DashboardStockSearchHistoryItem) => void;
|
||||
onRemove: (symbol: string) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 최근 검색 종목 목록을 보여주고, 재검색/개별삭제/전체삭제를 제공합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 검색 패널에서 종목 재선택 UI로 사용합니다.
|
||||
* @see features/dashboard/hooks/useStockSearch.ts searchHistory 상태를 화면에 렌더링합니다.
|
||||
*/
|
||||
export function StockSearchHistory({
|
||||
items,
|
||||
selectedSymbol,
|
||||
onSelect,
|
||||
onRemove,
|
||||
onClear,
|
||||
}: StockSearchHistoryProps) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="rounded-md border border-brand-200/80 bg-brand-50/45 p-2 dark:border-brand-700/50 dark:bg-brand-900/26">
|
||||
{/* ========== HISTORY HEADER ========== */}
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2 px-1">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-brand-700 dark:text-brand-200">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
최근 검색 종목
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
className="h-7 px-2 text-[11px] text-muted-foreground hover:text-foreground dark:text-brand-100/75 dark:hover:text-brand-50"
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
전체 삭제
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ========== HISTORY LIST ========== */}
|
||||
<div className="max-h-36 space-y-1 overflow-y-auto pr-1">
|
||||
{items.map((item) => {
|
||||
const isSelected = item.symbol === selectedSymbol;
|
||||
|
||||
return (
|
||||
<div key={item.symbol} className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onSelect(item)}
|
||||
className={cn(
|
||||
"h-8 flex-1 justify-between rounded-md border border-transparent px-2.5",
|
||||
"text-left hover:bg-white/80 dark:hover:bg-brand-800/35",
|
||||
isSelected &&
|
||||
"border-brand-300 bg-white text-brand-700 dark:border-brand-500/55 dark:bg-brand-800/40 dark:text-brand-100",
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-sm font-medium">{item.name}</span>
|
||||
<span className="ml-2 shrink-0 text-[11px] text-muted-foreground dark:text-brand-100/70">
|
||||
{item.symbol}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemove(item.symbol)}
|
||||
aria-label={`${item.name} 히스토리 삭제`}
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:bg-white/80 hover:text-foreground dark:text-brand-100/70 dark:hover:bg-brand-800/35 dark:hover:text-brand-50"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
// import { Activity, TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DashboardStockSearchItem } from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
interface StockSearchResultsProps {
|
||||
items: DashboardStockSearchItem[];
|
||||
onSelect: (item: DashboardStockSearchItem) => void;
|
||||
selectedSymbol?: string;
|
||||
}
|
||||
|
||||
export function StockSearchResults({
|
||||
items,
|
||||
onSelect,
|
||||
selectedSymbol,
|
||||
}: StockSearchResultsProps) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col p-2">
|
||||
{items.map((item) => {
|
||||
const isSelected = item.symbol === selectedSymbol;
|
||||
return (
|
||||
<Button
|
||||
key={item.symbol}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-auto w-full flex-col items-start gap-1 p-3 text-left",
|
||||
isSelected && "border-brand-500 bg-brand-50 hover:bg-brand-100",
|
||||
)}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="font-semibold truncate">{item.name}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{item.symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{item.market}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
import rawStocks from "@/features/dashboard/data/korean-stocks.json";
|
||||
import type { KoreanStockIndexItem } from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
/**
|
||||
* 국내주식 검색 인덱스(KOSPI + KOSDAQ)
|
||||
* - 파일 원본: korean-stocks.json
|
||||
* - 사용처: /api/kis/domestic/search 라우트의 메모리 검색
|
||||
* @see app/api/kis/domestic/search/route.ts 종목명/종목코드 검색에 사용합니다.
|
||||
*/
|
||||
export const KOREAN_STOCK_INDEX = rawStocks as KoreanStockIndexItem[];
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* @file features/dashboard/data/mock-stocks.ts
|
||||
* @description 대시보드 1단계 UI 검증용 목업 종목 데이터
|
||||
* @remarks
|
||||
* - 한국투자증권 API 연동 전까지 화면 동작 검증에 사용합니다.
|
||||
* - 2단계 이후 실제 화면은 app/api/kis/* 응답을 사용합니다.
|
||||
* - 현재는 레거시/비교용 샘플 데이터로만 남겨둔 상태입니다.
|
||||
*/
|
||||
|
||||
import type { DashboardStockItem } from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
/**
|
||||
* 대시보드 목업 종목 목록
|
||||
* @see app/(main)/dashboard/page.tsx DashboardPage가 DashboardMain을 통해 이 데이터를 조회합니다.
|
||||
* @see features/dashboard/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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockItem,
|
||||
DashboardStockOrderBookResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
interface UseCurrentPriceParams {
|
||||
stock?: DashboardStockItem | null;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
orderBook: DashboardStockOrderBookResponse | null;
|
||||
}
|
||||
|
||||
export function useCurrentPrice({
|
||||
stock,
|
||||
latestTick,
|
||||
orderBook,
|
||||
}: UseCurrentPriceParams) {
|
||||
return useMemo(() => {
|
||||
let currentPrice = stock?.currentPrice ?? 0;
|
||||
let change = stock?.change ?? 0;
|
||||
let changeRate = stock?.changeRate ?? 0;
|
||||
const prevClose = stock?.prevClose ?? 0;
|
||||
|
||||
// 1. Priority: Realtime Tick (Trade WS)
|
||||
if (latestTick?.price && latestTick.price > 0) {
|
||||
currentPrice = latestTick.price;
|
||||
change = latestTick.change;
|
||||
changeRate = latestTick.changeRate;
|
||||
}
|
||||
// 2. Fallback: OrderBook Best Ask (Proxy for current price if no tick)
|
||||
else if (
|
||||
orderBook?.levels[0]?.askPrice &&
|
||||
orderBook.levels[0].askPrice > 0
|
||||
) {
|
||||
const askPrice = orderBook.levels[0].askPrice;
|
||||
currentPrice = askPrice;
|
||||
|
||||
// Recalculate change/rate based on prevClose
|
||||
if (prevClose > 0) {
|
||||
change = currentPrice - prevClose;
|
||||
changeRate = (change / prevClose) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentPrice,
|
||||
change,
|
||||
changeRate,
|
||||
prevClose,
|
||||
};
|
||||
}, [stock, latestTick, orderBook]);
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type KisRuntimeCredentials,
|
||||
useKisRuntimeStore,
|
||||
} from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockOrderBookResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
buildKisRealtimeMessage,
|
||||
parseKisRealtimeOrderbook,
|
||||
parseKisRealtimeTickBatch,
|
||||
} from "@/features/dashboard/utils/kis-realtime.utils";
|
||||
import {
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
||||
resolveDomesticKisSession,
|
||||
shouldUseAfterHoursSinglePriceTr,
|
||||
shouldUseExpectedExecutionTr,
|
||||
type DomesticKisSession,
|
||||
} from "@/lib/kis/domestic-market-session";
|
||||
|
||||
const TRADE_TR_ID = "H0STCNT0";
|
||||
const TRADE_TR_ID_EXPECTED = "H0STANC0";
|
||||
const TRADE_TR_ID_OVERTIME = "H0STOUP0";
|
||||
const ORDERBOOK_TR_ID = "H0STASP0";
|
||||
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0";
|
||||
const MAX_TRADE_TICKS = 10;
|
||||
|
||||
function resolveTradeTrId(
|
||||
env: KisRuntimeCredentials["tradingEnv"],
|
||||
session: DomesticKisSession,
|
||||
) {
|
||||
if (env === "mock") return TRADE_TR_ID;
|
||||
if (shouldUseAfterHoursSinglePriceTr(session)) return TRADE_TR_ID_OVERTIME;
|
||||
if (shouldUseExpectedExecutionTr(session)) return TRADE_TR_ID_EXPECTED;
|
||||
return TRADE_TR_ID;
|
||||
}
|
||||
|
||||
function resolveOrderBookTrId(
|
||||
env: KisRuntimeCredentials["tradingEnv"],
|
||||
session: DomesticKisSession,
|
||||
) {
|
||||
if (env === "mock") return ORDERBOOK_TR_ID;
|
||||
if (shouldUseAfterHoursSinglePriceTr(session)) return ORDERBOOK_TR_ID_OVERTIME;
|
||||
return ORDERBOOK_TR_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Subscribes trade ticks and orderbook over one websocket.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx
|
||||
* @see lib/kis/domestic-market-session.ts
|
||||
*/
|
||||
export function useKisTradeWebSocket(
|
||||
symbol: string | undefined,
|
||||
credentials: KisRuntimeCredentials | null,
|
||||
isVerified: boolean,
|
||||
onTick?: (tick: DashboardRealtimeTradeTick) => void,
|
||||
options?: {
|
||||
orderBookSymbol?: string;
|
||||
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
|
||||
},
|
||||
) {
|
||||
const [latestTick, setLatestTick] =
|
||||
useState<DashboardRealtimeTradeTick | null>(null);
|
||||
const [recentTradeTicks, setRecentTradeTicks] = useState<
|
||||
DashboardRealtimeTradeTick[]
|
||||
>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
|
||||
const [marketSession, setMarketSession] = useState<DomesticKisSession>(() =>
|
||||
resolveSessionInClient(),
|
||||
);
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const approvalKeyRef = useRef<string | null>(null);
|
||||
const seenTickRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const obSymbol = options?.orderBookSymbol;
|
||||
const onOrderBookMsg = options?.onOrderBookMessage;
|
||||
const realtimeTrId = credentials
|
||||
? resolveTradeTrId(credentials.tradingEnv, marketSession)
|
||||
: TRADE_TR_ID;
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = window.setInterval(() => {
|
||||
const nextSession = resolveSessionInClient();
|
||||
setMarketSession((prev) => (prev === nextSession ? prev : nextSession));
|
||||
}, 30_000);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || lastTickAt) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setError(
|
||||
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간)과 TR ID를 확인해 주세요.",
|
||||
);
|
||||
}, 8000);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isConnected, lastTickAt]);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestTick(null);
|
||||
setRecentTradeTicks([]);
|
||||
setError(null);
|
||||
setLastTickAt(null);
|
||||
seenTickRef.current.clear();
|
||||
|
||||
if (!symbol || !isVerified || !credentials) {
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
approvalKeyRef.current = null;
|
||||
setIsConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
|
||||
const tradeTrId = resolveTradeTrId(credentials.tradingEnv, marketSession);
|
||||
const orderBookTrId = obSymbol
|
||||
? resolveOrderBookTrId(credentials.tradingEnv, marketSession)
|
||||
: null;
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsConnected(false);
|
||||
|
||||
const wsConnection = await useKisRuntimeStore
|
||||
.getState()
|
||||
.getOrFetchWsConnection();
|
||||
|
||||
if (!wsConnection) {
|
||||
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
approvalKeyRef.current = wsConnection.approvalKey;
|
||||
|
||||
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout/${tradeTrId}`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (disposed || !approvalKeyRef.current) return;
|
||||
|
||||
socket?.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
symbol,
|
||||
tradeTrId,
|
||||
"1",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (obSymbol && orderBookTrId) {
|
||||
socket?.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
obSymbol,
|
||||
orderBookTrId,
|
||||
"1",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setIsConnected(true);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (disposed || typeof event.data !== "string") return;
|
||||
|
||||
if (obSymbol && onOrderBookMsg) {
|
||||
const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol);
|
||||
if (orderBook) {
|
||||
orderBook.tradingEnv = credentials.tradingEnv;
|
||||
onOrderBookMsg(orderBook);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ticks = parseKisRealtimeTickBatch(event.data, symbol);
|
||||
if (ticks.length === 0) return;
|
||||
|
||||
const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0);
|
||||
const dedupedTicks = meaningfulTicks.filter((tick) => {
|
||||
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
|
||||
if (seenTickRef.current.has(key)) return false;
|
||||
seenTickRef.current.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
const latest = ticks[ticks.length - 1];
|
||||
setLatestTick(latest);
|
||||
|
||||
if (dedupedTicks.length > 0) {
|
||||
setRecentTradeTicks((prev) =>
|
||||
[...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
|
||||
);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setLastTickAt(Date.now());
|
||||
onTick?.(latest);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
if (!disposed) setIsConnected(false);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (!disposed) setIsConnected(false);
|
||||
};
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "실시간 웹소켓 초기화 중 오류가 발생했습니다.",
|
||||
);
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
void connect();
|
||||
const seenRef = seenTickRef.current;
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
setIsConnected(false);
|
||||
|
||||
const key = approvalKeyRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN && key) {
|
||||
socket.send(
|
||||
JSON.stringify(buildKisRealtimeMessage(key, symbol, tradeTrId, "2")),
|
||||
);
|
||||
|
||||
if (obSymbol && orderBookTrId) {
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(key, obSymbol, orderBookTrId, "2"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
socket?.close();
|
||||
if (socketRef.current === socket) socketRef.current = null;
|
||||
approvalKeyRef.current = null;
|
||||
seenRef.clear();
|
||||
};
|
||||
}, [
|
||||
symbol,
|
||||
isVerified,
|
||||
credentials,
|
||||
marketSession,
|
||||
onTick,
|
||||
obSymbol,
|
||||
onOrderBookMsg,
|
||||
]);
|
||||
|
||||
return {
|
||||
latestTick,
|
||||
recentTradeTicks,
|
||||
isConnected,
|
||||
error,
|
||||
lastTickAt,
|
||||
realtimeTrId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionInClient() {
|
||||
if (typeof window === "undefined") {
|
||||
return resolveDomesticKisSession();
|
||||
}
|
||||
|
||||
try {
|
||||
const override = window.localStorage.getItem(
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
||||
);
|
||||
return resolveDomesticKisSession(override);
|
||||
} catch {
|
||||
return resolveDomesticKisSession();
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardStockCashOrderRequest,
|
||||
DashboardStockCashOrderResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import { fetchOrderCash } from "@/features/dashboard/apis/kis-stock.api";
|
||||
|
||||
export function useOrder() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<DashboardStockCashOrderResponse | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const placeOrder = useCallback(
|
||||
async (
|
||||
request: DashboardStockCashOrderRequest,
|
||||
credentials: KisRuntimeCredentials | null,
|
||||
) => {
|
||||
if (!credentials) {
|
||||
setError("KIS API 자격 증명이 없습니다.");
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const data = await fetchOrderCash(request, credentials);
|
||||
setResult(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "주문 처리 중 오류가 발생했습니다.";
|
||||
setError(message);
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
placeOrder,
|
||||
isLoading,
|
||||
error,
|
||||
result,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import { fetchStockOrderBook } from "@/features/dashboard/apis/kis-stock.api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* @description 초기 REST 호가를 한 번 조회하고, 이후에는 웹소켓 호가를 우선 사용합니다.
|
||||
* 웹소켓 호가 데이터는 DashboardContainer에서 useKisTradeWebSocket을 통해
|
||||
* 단일 WebSocket으로 수신되어 externalRealtimeOrderBook으로 주입됩니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 호가 데이터 흐름
|
||||
* @see features/dashboard/components/orderbook/OrderBook.tsx 호가창 렌더링 데이터 공급
|
||||
*/
|
||||
export function useOrderBook(
|
||||
symbol: string | undefined,
|
||||
market: "KOSPI" | "KOSDAQ" | undefined,
|
||||
credentials: KisRuntimeCredentials | null,
|
||||
isVerified: boolean,
|
||||
options: {
|
||||
enabled?: boolean;
|
||||
/** 체결 WS에서 받은 실시간 호가 데이터 (단일 WS 통합) */
|
||||
externalRealtimeOrderBook?: DashboardStockOrderBookResponse | null;
|
||||
} = {},
|
||||
) {
|
||||
const { enabled = true, externalRealtimeOrderBook = null } = options;
|
||||
const isRequestEnabled = enabled && !!symbol && !!credentials;
|
||||
const requestSeqRef = useRef(0);
|
||||
const lastErrorToastRef = useRef<string>("");
|
||||
|
||||
const [initialData, setInitialData] =
|
||||
useState<DashboardStockOrderBookResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRequestEnabled || !symbol || !credentials) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestSeq = ++requestSeqRef.current;
|
||||
let isDisposed = false;
|
||||
|
||||
const loadInitialOrderBook = async () => {
|
||||
setInitialData(null);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchStockOrderBook(symbol, credentials);
|
||||
if (isDisposed || requestSeq !== requestSeqRef.current) return;
|
||||
setInitialData(data);
|
||||
} catch (err) {
|
||||
if (isDisposed || requestSeq !== requestSeqRef.current) return;
|
||||
console.error("Failed to fetch initial orderbook:", err);
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "호가 정보를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.";
|
||||
setError(message);
|
||||
|
||||
if (lastErrorToastRef.current !== message) {
|
||||
lastErrorToastRef.current = message;
|
||||
toast.error(message);
|
||||
}
|
||||
} finally {
|
||||
if (isDisposed || requestSeq !== requestSeqRef.current) return;
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadInitialOrderBook();
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
};
|
||||
}, [isRequestEnabled, symbol, credentials]);
|
||||
|
||||
// 외부 실시간 호가 → 초기 데이터 → null 순 우선
|
||||
const orderBook = isRequestEnabled
|
||||
? (externalRealtimeOrderBook ?? initialData)
|
||||
: null;
|
||||
const mergedError = isRequestEnabled ? error : null;
|
||||
const mergedLoading = isRequestEnabled ? isLoading && !orderBook : false;
|
||||
|
||||
return {
|
||||
orderBook,
|
||||
isLoading: mergedLoading,
|
||||
error: mergedError,
|
||||
isWsConnected: !!externalRealtimeOrderBook,
|
||||
};
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useCallback, useState, useTransition } from "react";
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardMarketPhase,
|
||||
DashboardPriceSource,
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockSearchItem,
|
||||
DashboardStockItem,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import { fetchStockOverview } from "@/features/dashboard/apis/kis-stock.api";
|
||||
|
||||
interface OverviewMeta {
|
||||
priceSource: DashboardPriceSource;
|
||||
marketPhase: DashboardMarketPhase;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export function useStockOverview() {
|
||||
const [selectedStock, setSelectedStock] = useState<DashboardStockItem | null>(
|
||||
null,
|
||||
);
|
||||
const [meta, setMeta] = useState<OverviewMeta | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, startTransition] = useTransition();
|
||||
|
||||
const loadOverview = useCallback(
|
||||
(
|
||||
symbol: string,
|
||||
credentials: KisRuntimeCredentials | null,
|
||||
marketHint?: DashboardStockSearchItem["market"],
|
||||
) => {
|
||||
if (!credentials) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const data = await fetchStockOverview(symbol, credentials);
|
||||
setSelectedStock({
|
||||
...data.stock,
|
||||
market: marketHint ?? data.stock.market,
|
||||
});
|
||||
setMeta({
|
||||
priceSource: data.priceSource,
|
||||
marketPhase: data.marketPhase,
|
||||
fetchedAt: data.fetchedAt,
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "종목 조회 중 오류가 발생했습니다.";
|
||||
setError(message);
|
||||
setMeta(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 실시간 체결 수신 시 헤더/주요 시세 상태만 갱신합니다.
|
||||
* 차트 캔들은 StockLineChart 내부 API 응답을 기준으로 유지합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx useKisTradeWebSocket onTick 전달
|
||||
* @see features/dashboard/components/chart/StockLineChart.tsx 차트 데이터 fetchStockChart 기준 렌더링
|
||||
*/
|
||||
const updateRealtimeTradeTick = useCallback(
|
||||
(tick: DashboardRealtimeTradeTick) => {
|
||||
setSelectedStock((prev) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
const { price, accumulatedVolume, change, changeRate } = tick;
|
||||
const nextChange = change;
|
||||
const nextChangeRate = Number.isFinite(changeRate)
|
||||
? changeRate
|
||||
: prev.prevClose > 0
|
||||
? (nextChange / prev.prevClose) * 100
|
||||
: prev.changeRate;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
currentPrice: price,
|
||||
change: nextChange,
|
||||
changeRate: nextChangeRate,
|
||||
high: prev.high > 0 ? Math.max(prev.high, price) : price,
|
||||
low: prev.low > 0 ? Math.min(prev.low, price) : price,
|
||||
volume: accumulatedVolume > 0 ? accumulatedVolume : prev.volume,
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedStock,
|
||||
setSelectedStock,
|
||||
meta,
|
||||
setMeta,
|
||||
error,
|
||||
setError,
|
||||
isLoading,
|
||||
loadOverview,
|
||||
updateRealtimeTradeTick,
|
||||
};
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { fetchStockSearch } from "@/features/dashboard/apis/kis-stock.api";
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardStockSearchHistoryItem,
|
||||
DashboardStockSearchItem,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
const SEARCH_HISTORY_STORAGE_KEY = "jurini:stock-search-history:v1";
|
||||
const SEARCH_HISTORY_LIMIT = 12;
|
||||
|
||||
interface StoredSearchHistory {
|
||||
version: 1;
|
||||
items: DashboardStockSearchHistoryItem[];
|
||||
}
|
||||
|
||||
function readSearchHistory(): DashboardStockSearchHistoryItem[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SEARCH_HISTORY_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
|
||||
const parsed = JSON.parse(raw) as StoredSearchHistory;
|
||||
if (parsed?.version !== 1 || !Array.isArray(parsed.items)) return [];
|
||||
|
||||
return parsed.items
|
||||
.filter((item) => item?.symbol && item?.name && item?.market)
|
||||
.slice(0, SEARCH_HISTORY_LIMIT);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const payload: StoredSearchHistory = {
|
||||
version: 1,
|
||||
items,
|
||||
};
|
||||
window.localStorage.setItem(SEARCH_HISTORY_STORAGE_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 종목 검색 상태(키워드/결과/에러)와 검색 히스토리(localStorage)를 함께 관리합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 검색 제출/자동검색/히스토리 클릭 이벤트에서 호출합니다.
|
||||
* @see features/dashboard/components/search/StockSearchHistory.tsx 히스토리 목록 렌더링 데이터로 사용합니다.
|
||||
*/
|
||||
export function useStockSearch() {
|
||||
// ========== SEARCH STATE ==========
|
||||
const [keyword, setKeyword] = useState("삼성전자");
|
||||
const [searchResults, setSearchResults] = useState<DashboardStockSearchItem[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// ========== SEARCH HISTORY STATE ==========
|
||||
const [searchHistory, setSearchHistory] = useState<DashboardStockSearchHistoryItem[]>(
|
||||
() => readSearchHistory(),
|
||||
);
|
||||
|
||||
// 동일 시점 중복 요청과 경합 응답을 막기 위한 취소 컨트롤러
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const loadSearch = useCallback(async (query: string) => {
|
||||
const controller = new AbortController();
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = controller;
|
||||
|
||||
setIsSearching(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchStockSearch(query, controller.signal);
|
||||
setSearchResults(data.items);
|
||||
return data.items;
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return [];
|
||||
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "종목 검색 중 오류가 발생했습니다.",
|
||||
);
|
||||
return [];
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* @description 검색어를 받아 종목 검색 API를 호출합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx handleSearchSubmit 자동/수동 검색에 사용합니다.
|
||||
*/
|
||||
const search = useCallback(
|
||||
(query: string, credentials: KisRuntimeCredentials | null) => {
|
||||
if (!credentials) {
|
||||
setError("API 키 검증이 필요합니다.");
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
abortRef.current?.abort();
|
||||
setSearchResults([]);
|
||||
setError(null);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadSearch(trimmed);
|
||||
},
|
||||
[loadSearch],
|
||||
);
|
||||
|
||||
/**
|
||||
* @description 검색 결과를 지우고 진행 중인 검색 요청을 중단합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 종목 선택 직후 검색 패널 정리에 사용합니다.
|
||||
*/
|
||||
const clearSearch = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
setSearchResults([]);
|
||||
setError(null);
|
||||
setIsSearching(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* @description API 검증 전 같은 상황에서 공통 에러 메시지를 표시하기 위한 setter입니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx ensureSearchReady 인증 가드에서 사용합니다.
|
||||
*/
|
||||
const setSearchError = useCallback((message: string | null) => {
|
||||
setError(message);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* @description 선택한 종목을 검색 히스토리 맨 위에 추가(중복 제거)합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx handleSelectStock 종목 선택 이벤트에서 호출합니다.
|
||||
*/
|
||||
const appendSearchHistory = useCallback((item: DashboardStockSearchItem) => {
|
||||
setSearchHistory((prev) => {
|
||||
const deduped = prev.filter((historyItem) => historyItem.symbol !== item.symbol);
|
||||
const nextItems: DashboardStockSearchHistoryItem[] = [
|
||||
{ ...item, savedAt: Date.now() },
|
||||
...deduped,
|
||||
].slice(0, SEARCH_HISTORY_LIMIT);
|
||||
|
||||
writeSearchHistory(nextItems);
|
||||
return nextItems;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* @description 종목코드 기준으로 히스토리 항목을 삭제합니다.
|
||||
* @see features/dashboard/components/search/StockSearchHistory.tsx 삭제 버튼 클릭 이벤트에서 호출합니다.
|
||||
*/
|
||||
const removeSearchHistory = useCallback((symbol: string) => {
|
||||
setSearchHistory((prev) => {
|
||||
const nextItems = prev.filter((item) => item.symbol !== symbol);
|
||||
writeSearchHistory(nextItems);
|
||||
return nextItems;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* @description 저장된 검색 히스토리를 전체 삭제합니다.
|
||||
* @see features/dashboard/components/search/StockSearchHistory.tsx 전체 삭제 버튼 이벤트에서 호출합니다.
|
||||
*/
|
||||
const clearSearchHistory = useCallback(() => {
|
||||
setSearchHistory([]);
|
||||
writeSearchHistory([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
keyword,
|
||||
setKeyword,
|
||||
searchResults,
|
||||
error,
|
||||
isSearching,
|
||||
search,
|
||||
clearSearch,
|
||||
setSearchError,
|
||||
searchHistory,
|
||||
appendSearchHistory,
|
||||
removeSearchHistory,
|
||||
clearSearchHistory,
|
||||
};
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { fetchKisWebSocketApproval } from "@/features/dashboard/apis/kis-auth.api";
|
||||
import type { KisTradingEnv } from "@/features/dashboard/types/dashboard.types";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { create } from "zustand";
|
||||
|
||||
/**
|
||||
* @file features/dashboard/store/use-kis-runtime-store.ts
|
||||
* @description Stores KIS input, verification, and websocket connection state.
|
||||
* @see features/dashboard/hooks/useKisTradeWebSocket.ts
|
||||
*/
|
||||
export interface KisRuntimeCredentials {
|
||||
appKey: string;
|
||||
appSecret: string;
|
||||
tradingEnv: KisTradingEnv;
|
||||
accountNo: string;
|
||||
}
|
||||
|
||||
interface KisWsConnection {
|
||||
approvalKey: string;
|
||||
wsUrl: string;
|
||||
}
|
||||
|
||||
interface KisRuntimeStoreState {
|
||||
kisTradingEnvInput: KisTradingEnv;
|
||||
kisAppKeyInput: string;
|
||||
kisAppSecretInput: string;
|
||||
kisAccountNoInput: string;
|
||||
|
||||
verifiedCredentials: KisRuntimeCredentials | null;
|
||||
isKisVerified: boolean;
|
||||
tradingEnv: KisTradingEnv;
|
||||
|
||||
wsApprovalKey: string | null;
|
||||
wsUrl: string | null;
|
||||
}
|
||||
|
||||
interface KisRuntimeStoreActions {
|
||||
setKisTradingEnvInput: (tradingEnv: KisTradingEnv) => void;
|
||||
setKisAppKeyInput: (appKey: string) => void;
|
||||
setKisAppSecretInput: (appSecret: string) => void;
|
||||
setKisAccountNoInput: (accountNo: string) => void;
|
||||
setVerifiedKisSession: (
|
||||
credentials: KisRuntimeCredentials,
|
||||
tradingEnv: KisTradingEnv,
|
||||
) => void;
|
||||
invalidateKisVerification: () => void;
|
||||
clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void;
|
||||
getOrFetchWsConnection: () => Promise<KisWsConnection | null>;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: KisRuntimeStoreState = {
|
||||
kisTradingEnvInput: "real",
|
||||
kisAppKeyInput: "",
|
||||
kisAppSecretInput: "",
|
||||
kisAccountNoInput: "",
|
||||
verifiedCredentials: null,
|
||||
isKisVerified: false,
|
||||
tradingEnv: "real",
|
||||
wsApprovalKey: null,
|
||||
wsUrl: null,
|
||||
};
|
||||
|
||||
const RESET_VERIFICATION_STATE = {
|
||||
verifiedCredentials: null,
|
||||
isKisVerified: false,
|
||||
wsApprovalKey: null,
|
||||
wsUrl: null,
|
||||
};
|
||||
|
||||
let wsConnectionPromise: Promise<KisWsConnection | null> | null = null;
|
||||
|
||||
/**
|
||||
* @description Runtime store for KIS session.
|
||||
* @see features/dashboard/components/auth/KisAuthForm.tsx
|
||||
*/
|
||||
export const useKisRuntimeStore = create<
|
||||
KisRuntimeStoreState & KisRuntimeStoreActions
|
||||
>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...INITIAL_STATE,
|
||||
|
||||
setKisTradingEnvInput: (tradingEnv) =>
|
||||
set({
|
||||
kisTradingEnvInput: tradingEnv,
|
||||
...RESET_VERIFICATION_STATE,
|
||||
}),
|
||||
|
||||
setKisAppKeyInput: (appKey) =>
|
||||
set({
|
||||
kisAppKeyInput: appKey,
|
||||
...RESET_VERIFICATION_STATE,
|
||||
}),
|
||||
|
||||
setKisAppSecretInput: (appSecret) =>
|
||||
set({
|
||||
kisAppSecretInput: appSecret,
|
||||
...RESET_VERIFICATION_STATE,
|
||||
}),
|
||||
|
||||
setKisAccountNoInput: (accountNo) =>
|
||||
set({
|
||||
kisAccountNoInput: accountNo,
|
||||
...RESET_VERIFICATION_STATE,
|
||||
}),
|
||||
|
||||
setVerifiedKisSession: (credentials, tradingEnv) =>
|
||||
set({
|
||||
verifiedCredentials: credentials,
|
||||
isKisVerified: true,
|
||||
tradingEnv,
|
||||
wsApprovalKey: null,
|
||||
wsUrl: null,
|
||||
}),
|
||||
|
||||
invalidateKisVerification: () =>
|
||||
set({
|
||||
...RESET_VERIFICATION_STATE,
|
||||
}),
|
||||
|
||||
clearKisRuntimeSession: (tradingEnv) =>
|
||||
set({
|
||||
kisTradingEnvInput: tradingEnv,
|
||||
kisAppKeyInput: "",
|
||||
kisAppSecretInput: "",
|
||||
kisAccountNoInput: "",
|
||||
...RESET_VERIFICATION_STATE,
|
||||
tradingEnv,
|
||||
}),
|
||||
|
||||
getOrFetchWsConnection: async () => {
|
||||
const { wsApprovalKey, wsUrl, verifiedCredentials } = get();
|
||||
|
||||
if (wsApprovalKey && wsUrl) {
|
||||
return { approvalKey: wsApprovalKey, wsUrl };
|
||||
}
|
||||
|
||||
if (!verifiedCredentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (wsConnectionPromise) {
|
||||
return wsConnectionPromise;
|
||||
}
|
||||
|
||||
wsConnectionPromise = (async () => {
|
||||
try {
|
||||
const data = await fetchKisWebSocketApproval(verifiedCredentials);
|
||||
if (!data.approvalKey || !data.wsUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextConnection = {
|
||||
approvalKey: data.approvalKey,
|
||||
wsUrl: data.wsUrl,
|
||||
} satisfies KisWsConnection;
|
||||
|
||||
set({
|
||||
wsApprovalKey: nextConnection.approvalKey,
|
||||
wsUrl: nextConnection.wsUrl,
|
||||
});
|
||||
|
||||
return nextConnection;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
} finally {
|
||||
wsConnectionPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return wsConnectionPromise;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "autotrade-kis-runtime-store",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
kisTradingEnvInput: state.kisTradingEnvInput,
|
||||
kisAppKeyInput: state.kisAppKeyInput,
|
||||
kisAppSecretInput: state.kisAppSecretInput,
|
||||
kisAccountNoInput: state.kisAccountNoInput,
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
isKisVerified: state.isKisVerified,
|
||||
tradingEnv: state.tradingEnv,
|
||||
// wsApprovalKey/wsUrl are kept in memory only (expiration-sensitive).
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,221 +0,0 @@
|
||||
/**
|
||||
* @file features/dashboard/types/dashboard.types.ts
|
||||
* @description 대시보드(검색/시세/차트)에서 공통으로 쓰는 타입 모음
|
||||
*/
|
||||
|
||||
export type KisTradingEnv = "real" | "mock";
|
||||
export type DashboardPriceSource =
|
||||
| "inquire-price"
|
||||
| "inquire-ccnl"
|
||||
| "inquire-overtime-price";
|
||||
export type DashboardMarketPhase = "regular" | "afterHours";
|
||||
|
||||
/**
|
||||
* KOSPI/KOSDAQ 종목 인덱스 항목
|
||||
*/
|
||||
export interface KoreanStockIndexItem {
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: "KOSPI" | "KOSDAQ";
|
||||
standardCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 1개 점(시점 + 가격)
|
||||
*/
|
||||
export interface StockCandlePoint {
|
||||
time: string;
|
||||
price: number;
|
||||
open?: number;
|
||||
high?: number;
|
||||
low?: number;
|
||||
close?: number;
|
||||
volume?: number;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export type DashboardChartTimeframe = "1m" | "30m" | "1h" | "1d" | "1w";
|
||||
|
||||
/**
|
||||
* 호가창 1레벨(가격 + 잔량)
|
||||
*/
|
||||
export interface DashboardOrderBookLevel {
|
||||
askPrice: number;
|
||||
bidPrice: number;
|
||||
askSize: number;
|
||||
bidSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 종목 상세 모델
|
||||
*/
|
||||
export interface DashboardStockItem {
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: "KOSPI" | "KOSDAQ";
|
||||
currentPrice: number;
|
||||
change: number;
|
||||
changeRate: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
prevClose: number;
|
||||
volume: number;
|
||||
candles: StockCandlePoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과 1개 항목
|
||||
*/
|
||||
export interface DashboardStockSearchItem {
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: "KOSPI" | "KOSDAQ";
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 히스토리 1개 항목
|
||||
* @see features/dashboard/hooks/useStockSearch.ts localStorage에 저장/복원할 때 사용합니다.
|
||||
*/
|
||||
export interface DashboardStockSearchHistoryItem
|
||||
extends DashboardStockSearchItem {
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종목 검색 API 응답
|
||||
*/
|
||||
export interface DashboardStockSearchResponse {
|
||||
query: string;
|
||||
items: DashboardStockSearchItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종목 개요 API 응답
|
||||
*/
|
||||
export interface DashboardStockOverviewResponse {
|
||||
stock: DashboardStockItem;
|
||||
source: "kis";
|
||||
priceSource: DashboardPriceSource;
|
||||
marketPhase: DashboardMarketPhase;
|
||||
tradingEnv: KisTradingEnv;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export interface DashboardStockChartResponse {
|
||||
symbol: string;
|
||||
timeframe: DashboardChartTimeframe;
|
||||
candles: StockCandlePoint[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종목 호가 API 응답
|
||||
*/
|
||||
export interface DashboardStockOrderBookResponse {
|
||||
symbol: string;
|
||||
source: "kis" | "REALTIME";
|
||||
levels: DashboardOrderBookLevel[];
|
||||
totalAskSize: number;
|
||||
totalBidSize: number;
|
||||
businessHour?: string;
|
||||
hourClassCode?: string;
|
||||
accumulatedVolume?: number;
|
||||
anticipatedPrice?: number;
|
||||
anticipatedVolume?: number;
|
||||
anticipatedTotalVolume?: number;
|
||||
anticipatedChange?: number;
|
||||
anticipatedChangeSign?: string;
|
||||
anticipatedChangeRate?: number;
|
||||
totalAskSizeDelta?: number;
|
||||
totalBidSizeDelta?: number;
|
||||
tradingEnv: KisTradingEnv | string;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 체결(틱) 1건 모델
|
||||
*/
|
||||
export interface DashboardRealtimeTradeTick {
|
||||
symbol: string;
|
||||
tickTime: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changeRate: number;
|
||||
tradeVolume: number;
|
||||
accumulatedVolume: number;
|
||||
tradeStrength: number;
|
||||
askPrice1: number;
|
||||
bidPrice1: number;
|
||||
sellExecutionCount: number;
|
||||
buyExecutionCount: number;
|
||||
netBuyExecutionCount: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
export type DashboardOrderSide = "buy" | "sell";
|
||||
export type DashboardOrderType = "limit" | "market";
|
||||
|
||||
/**
|
||||
* 국내주식 현금 주문 요청 모델
|
||||
*/
|
||||
export interface DashboardStockCashOrderRequest {
|
||||
symbol: string;
|
||||
side: DashboardOrderSide;
|
||||
orderType: DashboardOrderType;
|
||||
quantity: number;
|
||||
price: number;
|
||||
accountNo: string;
|
||||
accountProductCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 국내주식 현금 주문 응답 모델
|
||||
*/
|
||||
export interface DashboardStockCashOrderResponse {
|
||||
ok: boolean;
|
||||
tradingEnv: KisTradingEnv;
|
||||
message: string;
|
||||
orderNo?: string;
|
||||
orderTime?: string;
|
||||
orderOrgNo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 키 검증 API 응답
|
||||
*/
|
||||
export interface DashboardKisValidateResponse {
|
||||
ok: boolean;
|
||||
tradingEnv: KisTradingEnv;
|
||||
message: string;
|
||||
sample?: {
|
||||
symbol: string;
|
||||
name: string;
|
||||
currentPrice: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 키 접근 폐기 API 응답
|
||||
*/
|
||||
export interface DashboardKisRevokeResponse {
|
||||
ok: boolean;
|
||||
tradingEnv: KisTradingEnv;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 웹소켓 승인키 발급 API 응답
|
||||
*/
|
||||
export interface DashboardKisWsApprovalResponse {
|
||||
ok: boolean;
|
||||
tradingEnv: KisTradingEnv;
|
||||
message: string;
|
||||
approvalKey?: string;
|
||||
wsUrl?: string;
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import type {
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockOrderBookResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]);
|
||||
const ALLOWED_REALTIME_TRADE_TR_IDS = new Set([
|
||||
"H0STCNT0",
|
||||
"H0STANC0",
|
||||
"H0STOUP0",
|
||||
"H0STOAC0",
|
||||
]);
|
||||
|
||||
const TICK_FIELD_INDEX = {
|
||||
symbol: 0,
|
||||
tickTime: 1,
|
||||
price: 2,
|
||||
sign: 3,
|
||||
change: 4,
|
||||
changeRate: 5,
|
||||
open: 7,
|
||||
high: 8,
|
||||
low: 9,
|
||||
askPrice1: 10,
|
||||
bidPrice1: 11,
|
||||
tradeVolume: 12,
|
||||
accumulatedVolume: 13,
|
||||
sellExecutionCount: 15,
|
||||
buyExecutionCount: 16,
|
||||
netBuyExecutionCount: 17,
|
||||
tradeStrength: 18,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* KIS ?ㅼ떆媛?援щ룆/?댁젣 ?뱀냼耳?硫붿떆吏瑜??앹꽦?⑸땲??
|
||||
*/
|
||||
export function buildKisRealtimeMessage(
|
||||
approvalKey: string,
|
||||
symbol: string,
|
||||
trId: string,
|
||||
trType: "1" | "2",
|
||||
) {
|
||||
return {
|
||||
header: {
|
||||
approval_key: approvalKey,
|
||||
custtype: "P",
|
||||
tr_type: trType,
|
||||
"content-type": "utf-8",
|
||||
},
|
||||
body: {
|
||||
input: {
|
||||
tr_id: trId,
|
||||
tr_key: symbol,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ?ㅼ떆媛?泥닿껐 ?ㅽ듃由?raw)??諛곗뿴 ?⑥쐞濡??뚯떛?⑸땲??
|
||||
* - 諛곗튂 ?꾩넚(蹂듭닔 ?????뚮룄 紐⑤뱺 ?깆쓣 異붿텧
|
||||
* - ?щ낵 遺덉씪移?媛寃?0 ?댄븯 ?곗씠?곕뒗 ?쒖쇅
|
||||
*/
|
||||
export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
||||
if (!/^([01])\|/.test(raw)) return [] as DashboardRealtimeTradeTick[];
|
||||
|
||||
const parts = raw.split("|");
|
||||
if (parts.length < 4) return [] as DashboardRealtimeTradeTick[];
|
||||
|
||||
// TR ID check: regular tick / expected tick / after-hours tick.
|
||||
const receivedTrId = parts[1];
|
||||
if (!ALLOWED_REALTIME_TRADE_TR_IDS.has(receivedTrId)) {
|
||||
return [] as DashboardRealtimeTradeTick[];
|
||||
}
|
||||
|
||||
// if (parts[1] !== expectedTrId) return [] as DashboardRealtimeTradeTick[];
|
||||
|
||||
const tickCount = Number(parts[2] ?? "1");
|
||||
const values = parts[3].split("^");
|
||||
if (values.length === 0) return [] as DashboardRealtimeTradeTick[];
|
||||
|
||||
const parsedCount =
|
||||
Number.isInteger(tickCount) && tickCount > 0 ? tickCount : 1;
|
||||
const fieldsPerTick = Math.floor(values.length / parsedCount);
|
||||
if (fieldsPerTick <= TICK_FIELD_INDEX.tradeStrength) {
|
||||
return [] as DashboardRealtimeTradeTick[];
|
||||
}
|
||||
|
||||
const ticks: DashboardRealtimeTradeTick[] = [];
|
||||
|
||||
for (let index = 0; index < parsedCount; index++) {
|
||||
const base = index * fieldsPerTick;
|
||||
const symbol = readString(values, base + TICK_FIELD_INDEX.symbol);
|
||||
if (symbol !== expectedSymbol) {
|
||||
if (symbol.trim() !== expectedSymbol.trim()) {
|
||||
console.warn(
|
||||
`[KisRealtime] Symbol mismatch: received '${symbol}', expected '${expectedSymbol}'`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const price = readNumber(values, base + TICK_FIELD_INDEX.price);
|
||||
if (!Number.isFinite(price) || price <= 0) continue;
|
||||
|
||||
const sign = readString(values, base + TICK_FIELD_INDEX.sign);
|
||||
const rawChange = readNumber(values, base + TICK_FIELD_INDEX.change);
|
||||
const rawChangeRate = readNumber(
|
||||
values,
|
||||
base + TICK_FIELD_INDEX.changeRate,
|
||||
);
|
||||
const change = REALTIME_SIGN_NEGATIVE.has(sign)
|
||||
? -Math.abs(rawChange)
|
||||
: rawChange;
|
||||
const changeRate = REALTIME_SIGN_NEGATIVE.has(sign)
|
||||
? -Math.abs(rawChangeRate)
|
||||
: rawChangeRate;
|
||||
|
||||
ticks.push({
|
||||
symbol,
|
||||
tickTime: readString(values, base + TICK_FIELD_INDEX.tickTime),
|
||||
price,
|
||||
change,
|
||||
changeRate,
|
||||
tradeVolume: readNumber(values, base + TICK_FIELD_INDEX.tradeVolume),
|
||||
accumulatedVolume: readNumber(
|
||||
values,
|
||||
base + TICK_FIELD_INDEX.accumulatedVolume,
|
||||
),
|
||||
tradeStrength: readNumber(values, base + TICK_FIELD_INDEX.tradeStrength),
|
||||
askPrice1: readNumber(values, base + TICK_FIELD_INDEX.askPrice1),
|
||||
bidPrice1: readNumber(values, base + TICK_FIELD_INDEX.bidPrice1),
|
||||
sellExecutionCount: readNumber(
|
||||
values,
|
||||
base + TICK_FIELD_INDEX.sellExecutionCount,
|
||||
),
|
||||
buyExecutionCount: readNumber(
|
||||
values,
|
||||
base + TICK_FIELD_INDEX.buyExecutionCount,
|
||||
),
|
||||
netBuyExecutionCount: readNumber(
|
||||
values,
|
||||
base + TICK_FIELD_INDEX.netBuyExecutionCount,
|
||||
),
|
||||
open: readNumber(values, base + TICK_FIELD_INDEX.open),
|
||||
high: readNumber(values, base + TICK_FIELD_INDEX.high),
|
||||
low: readNumber(values, base + TICK_FIELD_INDEX.low),
|
||||
});
|
||||
}
|
||||
|
||||
return ticks;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS ?ㅼ떆媛??멸?(H0STASP0/H0UNASP0/H0STOAA0)瑜?OrderBook 援ъ“濡??뚯떛?⑸땲??
|
||||
*/
|
||||
export function parseKisRealtimeOrderbook(
|
||||
raw: string,
|
||||
expectedSymbol: string,
|
||||
): DashboardStockOrderBookResponse | null {
|
||||
if (!/^([01])\|/.test(raw)) return null;
|
||||
|
||||
const parts = raw.split("|");
|
||||
if (parts.length < 4) return null;
|
||||
const trId = parts[1];
|
||||
if (trId !== "H0STASP0" && trId !== "H0UNASP0" && trId !== "H0STOAA0") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const values = parts[3].split("^");
|
||||
const levelCount = trId === "H0STOAA0" ? 9 : 10;
|
||||
|
||||
const symbol = values[0]?.trim() ?? "";
|
||||
const normalizedSymbol = normalizeDomesticSymbol(symbol);
|
||||
const normalizedExpected = normalizeDomesticSymbol(expectedSymbol);
|
||||
if (normalizedSymbol !== normalizedExpected) return null;
|
||||
|
||||
const askPriceStart = 3;
|
||||
const bidPriceStart = askPriceStart + levelCount;
|
||||
const askSizeStart = bidPriceStart + levelCount;
|
||||
const bidSizeStart = askSizeStart + levelCount;
|
||||
const totalAskIndex = bidSizeStart + levelCount;
|
||||
const totalBidIndex = totalAskIndex + 1;
|
||||
const anticipatedPriceIndex = totalBidIndex + 3;
|
||||
const anticipatedVolumeIndex = anticipatedPriceIndex + 1;
|
||||
const anticipatedTotalVolumeIndex = anticipatedPriceIndex + 2;
|
||||
const anticipatedChangeIndex = anticipatedPriceIndex + 3;
|
||||
const anticipatedChangeSignIndex = anticipatedPriceIndex + 4;
|
||||
const anticipatedChangeRateIndex = anticipatedPriceIndex + 5;
|
||||
const accumulatedVolumeIndex = anticipatedPriceIndex + 6;
|
||||
const totalAskDeltaIndex = anticipatedPriceIndex + 7;
|
||||
const totalBidDeltaIndex = anticipatedPriceIndex + 8;
|
||||
const minFieldLength = totalBidDeltaIndex + 1;
|
||||
|
||||
if (values.length < minFieldLength) return null;
|
||||
|
||||
const realtimeLevels = Array.from({ length: levelCount }, (_, i) => ({
|
||||
askPrice: readNumber(values, askPriceStart + i),
|
||||
bidPrice: readNumber(values, bidPriceStart + i),
|
||||
askSize: readNumber(values, askSizeStart + i),
|
||||
bidSize: readNumber(values, bidSizeStart + i),
|
||||
}));
|
||||
|
||||
return {
|
||||
symbol: normalizedExpected,
|
||||
totalAskSize: readNumber(values, totalAskIndex),
|
||||
totalBidSize: readNumber(values, totalBidIndex),
|
||||
businessHour: readString(values, 1),
|
||||
hourClassCode: readString(values, 2),
|
||||
anticipatedPrice: readNumber(values, anticipatedPriceIndex),
|
||||
anticipatedVolume: readNumber(values, anticipatedVolumeIndex),
|
||||
anticipatedTotalVolume: readNumber(values, anticipatedTotalVolumeIndex),
|
||||
anticipatedChange: readNumber(values, anticipatedChangeIndex),
|
||||
anticipatedChangeSign: readString(values, anticipatedChangeSignIndex),
|
||||
anticipatedChangeRate: readNumber(values, anticipatedChangeRateIndex),
|
||||
accumulatedVolume: readNumber(values, accumulatedVolumeIndex),
|
||||
totalAskSizeDelta: readNumber(values, totalAskDeltaIndex),
|
||||
totalBidSizeDelta: readNumber(values, totalBidDeltaIndex),
|
||||
levels: realtimeLevels,
|
||||
source: "REALTIME",
|
||||
tradingEnv: "real",
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 援?궡 醫낅ぉ肄붾뱶 鍮꾧탳瑜??꾪빐 ?묐몢 臾몄옄瑜??쒓굅?섍퀬 6?먮━ 肄붾뱶濡??뺢퇋?뷀빀?덈떎.
|
||||
* @see features/dashboard/utils/kis-realtime.utils.ts parseKisRealtimeOrderbook 醫낅ぉ 留ㅼ묶 鍮꾧탳
|
||||
*/
|
||||
function normalizeDomesticSymbol(value: string) {
|
||||
const trimmed = value.trim();
|
||||
const digits = trimmed.replace(/\D/g, "");
|
||||
|
||||
if (digits.length >= 6) {
|
||||
return digits.slice(-6);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function readString(values: string[], index: number) {
|
||||
return (values[index] ?? "").trim();
|
||||
}
|
||||
|
||||
function readNumber(values: string[], index: number) {
|
||||
const raw = readString(values, index).replaceAll(",", "");
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user