대시보드 중간 커밋

This commit is contained in:
2026-02-10 11:16:39 +09:00
parent 851a2acd69
commit 105d75e1f8
52 changed files with 6826 additions and 1287 deletions

View File

@@ -13,6 +13,7 @@
import { useEffect, useState } from "react";
import { useSessionStore } from "@/stores/session-store";
import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
import { cn } from "@/lib/utils";
/**
* 세션 만료 타이머 컴포넌트
@@ -21,7 +22,12 @@ import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
* @remarks 1초마다 리렌더링 발생
* @see header.tsx - 로그인 상태일 때 헤더에 표시
*/
export function SessionTimer() {
interface SessionTimerProps {
/** 셰이더 배경 위에서 가독성을 높이는 투명 모드 */
blendWithBackground?: boolean;
}
export function SessionTimer({ blendWithBackground = false }: SessionTimerProps) {
const lastActive = useSessionStore((state) => state.lastActive);
// [State] 남은 시간 (밀리초)
@@ -54,11 +60,14 @@ export function SessionTimer() {
return (
<div
className={`text-sm font-medium tabular-nums px-3 py-1.5 rounded-md border bg-background/50 backdrop-blur-md hidden md:block transition-colors ${
className={cn(
"hidden rounded-full border px-3 py-1.5 text-sm font-medium tabular-nums backdrop-blur-md transition-colors md:block",
isUrgent
? "text-red-500 border-red-200 bg-red-50/50 dark:bg-red-900/20 dark:border-red-800"
: "text-muted-foreground border-border/40"
}`}
? "border-red-200 bg-red-50/50 text-red-500 dark:border-red-800 dark:bg-red-900/20"
: blendWithBackground
? "border-white/30 bg-black/45 text-white shadow-sm shadow-black/40"
: "border-border/40 bg-background/50 text-muted-foreground",
)}
>
{/* ========== 라벨 ========== */}
<span className="mr-2"> </span>

View File

@@ -0,0 +1,82 @@
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardKisRevokeResponse,
DashboardKisValidateResponse,
DashboardKisWsApprovalResponse,
} from "@/features/dashboard/types/dashboard.types";
/**
* KIS API 키 검증 요청
* @param credentials 검증할 키 정보
*/
export async function validateKisCredentials(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisValidateResponse> {
const response = await fetch("/api/kis/validate", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as DashboardKisValidateResponse;
if (!response.ok || !payload.ok) {
throw new Error(payload.message || "API 키 검증에 실패했습니다.");
}
return payload;
}
/**
* KIS 접근토큰 폐기 요청
* @param credentials 폐기할 키 정보
*/
export async function revokeKisCredentials(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisRevokeResponse> {
const response = await fetch("/api/kis/revoke", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as DashboardKisRevokeResponse;
if (!response.ok || !payload.ok) {
throw new Error(payload.message || "API 키 접근 폐기에 실패했습니다.");
}
return payload;
}
/**
* KIS 실시간 웹소켓 승인키 발급 요청
* @param credentials 인증 정보
*/
export async function fetchKisWebSocketApproval(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisWsApprovalResponse> {
const response = await fetch("/api/kis/ws/approval", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as DashboardKisWsApprovalResponse;
if (!response.ok || !payload.ok || !payload.approvalKey || !payload.wsUrl) {
throw new Error(
payload.message || "KIS 실시간 웹소켓 승인키 발급에 실패했습니다.",
);
}
return payload;
}

View File

@@ -0,0 +1,179 @@
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";
/**
* 종목 검색 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: {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
},
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: {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
},
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: {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
},
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: {
"content-type": "application/json",
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
},
body: JSON.stringify(request),
cache: "no-store",
});
const payload = (await response.json()) as DashboardStockCashOrderResponse;
if (!response.ok) {
throw new Error(payload.message || "주문 전송 중 오류가 발생했습니다.");
}
return payload;
}

View File

@@ -0,0 +1,279 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils";
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 { 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 { 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 {
DashboardStockItem,
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 { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
useShallow((state) => ({
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
})),
);
const {
keyword,
setKeyword,
searchResults,
setSearchResults,
setError: setSearchError,
isSearching,
search,
clearSearch,
} = 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, realtimeCandles, 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,
},
);
useEffect(() => {
if (skipNextAutoSearchRef.current) {
skipNextAutoSearchRef.current = false;
return;
}
if (!isKisVerified || !verifiedCredentials) {
clearSearch();
return;
}
const trimmed = keyword.trim();
if (!trimmed) {
clearSearch();
return;
}
const timer = window.setTimeout(() => {
search(trimmed, verifiedCredentials);
}, 220);
return () => window.clearTimeout(timer);
}, [keyword, isKisVerified, verifiedCredentials, search, clearSearch]);
// Price Calculation Logic
// Prioritize latestTick (Real Exec) > OrderBook Ask1 (Proxy) > REST Data
let currentPrice = selectedStock?.currentPrice;
let change = selectedStock?.change;
let changeRate = selectedStock?.changeRate;
if (latestTick) {
currentPrice = latestTick.price;
change = latestTick.change;
changeRate = latestTick.changeRate;
} else if (orderBook?.levels[0]?.askPrice) {
// Fallback: Use Best Ask Price as proxy for current price
const askPrice = orderBook.levels[0].askPrice;
if (askPrice > 0) {
currentPrice = askPrice;
// Recalculate change/rate based on prevClose
if (selectedStock && selectedStock.prevClose > 0) {
change = currentPrice - selectedStock.prevClose;
changeRate = (change / selectedStock.prevClose) * 100;
}
}
}
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault();
if (!isKisVerified || !verifiedCredentials) {
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return;
}
search(keyword, verifiedCredentials);
}
function handleSelectStock(item: DashboardStockSearchItem) {
if (!isKisVerified || !verifiedCredentials) {
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return;
}
// 이미 선택된 종목을 다시 누른 경우 불필요한 개요 API 재호출을 막습니다.
if (selectedStock?.symbol === item.symbol) {
setSearchResults([]);
return;
}
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 돌지 않도록 1회 건너뜁니다.
skipNextAutoSearchRef.current = true;
setKeyword(item.name);
setSearchResults([]);
loadOverview(item.symbol, verifiedCredentials, item.market);
}
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">
<div className="flex items-center justify-between px-4 py-2 text-xs">
<div className="flex items-center gap-2">
<span className="font-semibold">KIS API :</span>
{isKisVerified ? (
<span className="text-green-600 font-medium flex items-center">
<span className="mr-1.5 h-2 w-2 rounded-full bg-green-500 ring-2 ring-green-100" />
(
{verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
</span>
) : (
<span className="text-muted-foreground flex items-center">
<span className="mr-1.5 h-2 w-2 rounded-full bg-gray-300" />
</span>
)}
</div>
</div>
<div className="overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out max-h-[500px] opacity-100">
<div className="p-4 border-t bg-background">
<KisAuthForm />
</div>
</div>
</div>
{/* ========== SEARCH ========== */}
<div className="flex-none p-4 border-b bg-background/95 backdrop-blur-sm z-30">
<div className="max-w-2xl mx-auto space-y-2 relative">
<StockSearchForm
keyword={keyword}
onKeywordChange={setKeyword}
onSubmit={handleSearchSubmit}
disabled={!isKisVerified}
isLoading={isSearching}
/>
{searchResults.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 bg-background border rounded-md shadow-lg max-h-80 overflow-y-auto overflow-x-hidden">
<StockSearchResults
items={searchResults}
onSelect={handleSelectStock}
selectedSymbol={
(selectedStock as DashboardStockItem | null)?.symbol
}
/>
</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} // High/Low/Vol only from Tick or Static
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={
realtimeCandles.length > 0
? realtimeCandles
: selectedStock.candles
}
credentials={verifiedCredentials}
/>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
</div>
)
}
orderBook={
<OrderBook
symbol={selectedStock?.symbol}
referencePrice={selectedStock?.prevClose}
currentPrice={currentPrice}
latestTick={latestTick}
recentTicks={recentTradeTicks}
orderBook={orderBook}
isLoading={isOrderBookLoading}
/>
}
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
import { useState, useTransition } from "react";
import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
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";
/**
* @description KIS 인증 입력 폼
* @see features/dashboard/store/use-kis-runtime-store.ts 인증 입력값/검증 상태를 저장합니다.
*/
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();
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 (
<Card className="border-brand-200 bg-linear-to-r from-brand-50/60 to-background">
<CardHeader>
<CardTitle>KIS API </CardTitle>
<CardDescription>
, API .
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* ========== CREDENTIAL INPUTS ========== */}
<div className="grid gap-3 md:grid-cols-3">
<div>
<label className="mb-1 block text-xs text-muted-foreground">
</label>
<div className="flex gap-2">
<Button
type="button"
variant={kisTradingEnvInput === "real" ? "default" : "outline"}
className={cn(
"flex-1",
kisTradingEnvInput === "real"
? "bg-brand-600 hover:bg-brand-700"
: "",
)}
onClick={() => setKisTradingEnvInput("real")}
>
</Button>
<Button
type="button"
variant={kisTradingEnvInput === "mock" ? "default" : "outline"}
className={cn(
"flex-1",
kisTradingEnvInput === "mock"
? "bg-brand-600 hover:bg-brand-700"
: "",
)}
onClick={() => setKisTradingEnvInput("mock")}
>
</Button>
</div>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">
KIS App Key
</label>
<Input
type="password"
value={kisAppKeyInput}
onChange={(e) => setKisAppKeyInput(e.target.value)}
placeholder="App Key 입력"
autoComplete="off"
/>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">
KIS App Secret
</label>
<Input
type="password"
value={kisAppSecretInput}
onChange={(e) => setKisAppSecretInput(e.target.value)}
placeholder="App Secret 입력"
autoComplete="off"
/>
</div>
</div>
{/* ========== ACTIONS ========== */}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
onClick={handleValidate}
disabled={
isValidating || !kisAppKeyInput.trim() || !kisAppSecretInput.trim()
}
className="bg-brand-600 hover:bg-brand-700"
>
{isValidating ? "검증 중..." : "API 키 검증"}
</Button>
<Button
type="button"
variant="outline"
onClick={handleRevoke}
disabled={isRevoking || !isKisVerified || !verifiedCredentials}
className="border-brand-200 text-brand-700 hover:bg-brand-50 hover:text-brand-800"
>
{isRevoking ? "해제 중..." : "연결 끊기"}
</Button>
{isKisVerified ? (
<span className="flex items-center text-sm font-medium text-green-600">
<span className="mr-1.5 h-2 w-2 rounded-full bg-green-500 ring-2 ring-green-100" />
({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
</span>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</div>
{errorMessage && (
<div className="text-sm font-medium text-destructive">{errorMessage}</div>
)}
{statusMessage && <div className="text-sm text-blue-600">{statusMessage}</div>}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,636 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CandlestickSeries,
ColorType,
HistogramSeries,
createChart,
type IChartApi,
type ISeriesApi,
type Time,
type UTCTimestamp,
} from "lightweight-charts";
import { ChevronDown } from "lucide-react";
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,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
import { cn } from "@/lib/utils";
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
const UP_COLOR = "#ef4444";
const DOWN_COLOR = "#2563eb";
// 분봉 드롭다운 옵션
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: "주" },
];
type ChartBar = {
time: UTCTimestamp;
open: number;
high: number;
low: number;
close: number;
volume: number;
};
interface StockLineChartProps {
symbol?: string;
candles: StockCandlePoint[];
credentials?: KisRuntimeCredentials | null;
}
/**
* @description TradingView 스타일 캔들 차트 + 거래량 + 무한 과거 로딩
* @see https://tradingview.github.io/lightweight-charts/tutorials/demos/realtime-updates
* @see https://tradingview.github.io/lightweight-charts/tutorials/demos/infinite-history
*/
export function StockLineChart({
symbol,
candles,
credentials,
}: StockLineChartProps) {
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 loadingMoreRef = useRef(false);
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
const initialLoadCompleteRef = useRef(false);
// candles prop을 ref로 관리하여 useEffect 디펜던시에서 제거 (무한 페칭 방지)
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]);
const setSeriesData = useCallback((nextBars: ChartBar[]) => {
const candleSeries = candleSeriesRef.current;
const volumeSeries = volumeSeriesRef.current;
if (!candleSeries || !volumeSeries) return;
// lightweight-charts는 시간 오름차순/유효 숫자 조건이 깨지면 렌더를 멈출 수 있어
// 전달 직전 데이터를 한 번 더 정리합니다.
const safeBars = nextBars;
try {
candleSeries.setData(
safeBars.map((bar) => ({
time: bar.time,
open: bar.open,
high: bar.high,
low: bar.low,
close: bar.close,
})),
);
volumeSeries.setData(
safeBars.map((bar) => ({
time: bar.time,
value: Number.isFinite(bar.volume) ? bar.volume : 0,
color:
bar.close >= bar.open
? "rgba(239,68,68,0.45)"
: "rgba(37,99,235,0.45)",
})),
);
} catch (error) {
console.error("Failed to render chart series data:", error);
}
}, []);
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 older = normalizeCandles(response.candles, timeframe);
setBars((prev) => mergeBars(older, 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(() => {
const container = containerRef.current;
if (!container || chartRef.current) return;
const initialWidth = Math.max(container.clientWidth, 320);
const initialHeight = Math.max(container.clientHeight, 340);
const chart = createChart(container, {
width: initialWidth,
height: initialHeight,
layout: {
background: { type: ColorType.Solid, color: "#ffffff" },
textColor: "#475569",
attributionLogo: true,
},
localization: {
locale: "ko-KR",
},
rightPriceScale: {
borderColor: "#e2e8f0",
scaleMargins: {
top: 0.08,
bottom: 0.24,
},
},
grid: {
vertLines: { color: "#edf1f5" },
horzLines: { color: "#edf1f5" },
},
crosshair: {
vertLine: { color: "#94a3b8", width: 1, style: 2 },
horzLine: { color: "#94a3b8", width: 1, style: 2 },
},
timeScale: {
borderColor: "#e2e8f0",
timeVisible: true,
secondsVisible: false,
rightOffset: 2,
},
handleScroll: {
mouseWheel: true,
pressedMouseMove: true,
},
handleScale: {
mouseWheel: true,
pinch: true,
axisPressedMouseMove: true,
},
});
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: UP_COLOR,
downColor: DOWN_COLOR,
wickUpColor: UP_COLOR,
wickDownColor: DOWN_COLOR,
borderUpColor: UP_COLOR,
borderDownColor: DOWN_COLOR,
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: NodeJS.Timeout | null = null;
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
if (!range) return;
// 초기 로딩 완료 후에만 무한 스크롤 트리거
// range.from이 0에 가까워지면(과거 데이터 필요) 로딩
if (range.from < 10 && initialLoadCompleteRef.current) {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
void loadMoreHandlerRef.current();
}, 300); // 300ms 디바운스
}
});
chartRef.current = chart;
candleSeriesRef.current = candleSeries;
volumeSeriesRef.current = volumeSeries;
setIsChartReady(true);
const resizeObserver = new ResizeObserver(() => {
const nextWidth = Math.max(container.clientWidth, 320);
const nextHeight = Math.max(container.clientHeight, 340);
chart.resize(nextWidth, nextHeight);
});
resizeObserver.observe(container);
// 첫 렌더 직후 부모 레이아웃 계산이 끝난 시점에 한 번 더 사이즈를 맞춥니다.
const rafId = window.requestAnimationFrame(() => {
const nextWidth = Math.max(container.clientWidth, 320);
const nextHeight = Math.max(container.clientHeight, 340);
chart.resize(nextWidth, nextHeight);
});
return () => {
window.cancelAnimationFrame(rafId);
resizeObserver.disconnect();
chart.remove();
chartRef.current = null;
candleSeriesRef.current = null;
volumeSeriesRef.current = null;
setIsChartReady(false);
};
}, []);
useEffect(() => {
if (symbol && credentials) return;
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 response = await fetchStockChart(symbol, timeframe, credentials);
if (disposed) return;
const normalized = normalizeCandles(response.candles, timeframe);
setBars(normalized);
setNextCursor(response.hasMore ? response.nextCursor : null);
// 초기 로딩 완료 후 500ms 지연 후 무한 스크롤 활성화
// (fitContent 후 range가 0이 되어 즉시 트리거되는 것 방지)
setTimeout(() => {
if (!disposed) {
initialLoadCompleteRef.current = true;
}
}, 500);
} catch (error) {
if (disposed) return;
const message =
error instanceof Error ? error.message : "차트 조회에 실패했습니다.";
toast.error(message);
// 에러 발생 시 fallback으로 props로 전달된 candles 사용
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);
// 초기 로딩 시에만 fitContent 수행
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
chartRef.current?.timeScale().fitContent();
}
}, [isChartReady, renderableBars, setSeriesData]);
const latestRealtime = useMemo(() => candles.at(-1), [candles]);
useEffect(() => {
if (!latestRealtime || bars.length === 0) return;
if (timeframe === "1w" && !latestRealtime.timestamp && !latestRealtime.time)
return;
const key = `${latestRealtime.time}-${latestRealtime.price}-${latestRealtime.volume ?? 0}`;
if (lastRealtimeKeyRef.current === key) return;
lastRealtimeKeyRef.current = key;
const nextBar = convertRealtimePointToBar(latestRealtime, timeframe);
if (!nextBar) return;
setBars((prev) => upsertRealtimeBar(prev, nextBar));
}, [bars.length, candles, latestRealtime, 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">
{/* ========== CHART TOOLBAR ========== */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-2 py-2 sm:px-3">
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
{/* 분봉 드롭다운 */}
<div className="relative">
<button
type="button"
onClick={() => setIsMinuteDropdownOpen((v) => !v)}
onBlur={() =>
setTimeout(() => setIsMinuteDropdownOpen(false), 200)
}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
MINUTE_TIMEFRAMES.some((t) => t.value === timeframe) &&
"bg-brand-100 font-semibold text-brand-700",
)}
>
{MINUTE_TIMEFRAMES.find((t) => t.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-slate-200 bg-white shadow-lg">
{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-slate-100",
timeframe === item.value &&
"bg-brand-50 font-semibold text-brand-700",
)}
>
{item.label}
</button>
))}
</div>
)}
</div>
{/* 일/주 버튼 */}
{PERIOD_TIMEFRAMES.map((item) => (
<button
key={item.value}
type="button"
onClick={() => setTimeframe(item.value)}
className={cn(
"rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
timeframe === item.value &&
"bg-brand-100 font-semibold text-brand-700",
)}
>
{item.label}
</button>
))}
{isLoadingMore && (
<span className="ml-2 text-[11px] text-muted-foreground">
...
</span>
)}
</div>
<div className="text-[11px] text-slate-600 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")}>
{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">
{statusMessage}
</div>
)}
</div>
</div>
);
}
function normalizeCandles(
candles: StockCandlePoint[],
timeframe: DashboardChartTimeframe,
) {
const rows = candles
.map((item) => convertCandleToBar(item, timeframe))
.filter((item): item is ChartBar => Boolean(item));
return mergeBars([], rows);
}
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 {
if (
typeof candle.timestamp === "number" &&
Number.isFinite(candle.timestamp)
) {
return adjustTimestampForTimeframe(candle.timestamp, timeframe);
}
const timeText = typeof candle.time === "string" ? candle.time.trim() : "";
if (!timeText) return null;
if (/^\d{2}\/\d{2}$/.test(timeText)) {
const [mm, dd] = timeText.split("/");
const year = new Date().getFullYear();
const d = new Date(`${year}-${mm}-${dd}T09:00:00+09:00`);
return adjustTimestampForTimeframe(
Math.floor(d.getTime() / 1000),
timeframe,
);
}
if (/^\d{2}:\d{2}(:\d{2})?$/.test(timeText)) {
const [hh, mi, ss] = timeText.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 = new Date(
`${y}-${m}-${d}T${hh}:${mi}:${ss ?? "00"}+09:00`,
).getTime();
return adjustTimestampForTimeframe(Math.floor(ts / 1000), timeframe);
}
return null;
}
function adjustTimestampForTimeframe(
timestamp: number,
timeframe: DashboardChartTimeframe,
): UTCTimestamp {
const date = new Date(timestamp * 1000);
if (timeframe === "30m" || timeframe === "1h") {
const bucketMinutes = timeframe === "30m" ? 30 : 60;
const mins = date.getUTCMinutes();
const aligned = Math.floor(mins / bucketMinutes) * bucketMinutes;
date.setUTCMinutes(aligned, 0, 0);
} else if (timeframe === "1d") {
date.setUTCHours(0, 0, 0, 0);
} else if (timeframe === "1w") {
const day = date.getUTCDay();
const diff = day === 0 ? -6 : 1 - day;
date.setUTCDate(date.getUTCDate() + diff);
date.setUTCHours(0, 0, 0, 0);
}
return Math.floor(date.getTime() / 1000) as UTCTimestamp;
}
function mergeBars(left: ChartBar[], right: 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);
}
function convertRealtimePointToBar(
point: StockCandlePoint,
timeframe: DashboardChartTimeframe,
) {
return convertCandleToBar(point, timeframe);
}
function upsertRealtimeBar(prev: ChartBar[], incoming: 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),
},
];
}
function formatPrice(value: number) {
return KRW_FORMATTER.format(Math.round(value));
}
function formatSignedPercent(value: number) {
const sign = value > 0 ? "+" : "";
return `${sign}${value.toFixed(2)}%`;
}

View File

@@ -1,999 +0,0 @@
"use client";
import { FormEvent, useCallback, useEffect, useRef, useState, useTransition } from "react";
import { Activity, Search, ShieldCheck, TrendingDown, TrendingUp } from "lucide-react";
import { useShallow } from "zustand/react/shallow";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
useKisRuntimeStore,
type KisRuntimeCredentials,
} from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardMarketPhase,
DashboardKisRevokeResponse,
DashboardPriceSource,
DashboardKisValidateResponse,
DashboardKisWsApprovalResponse,
DashboardStockItem,
DashboardStockOverviewResponse,
DashboardStockSearchItem,
DashboardStockSearchResponse,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
/**
* @file features/dashboard/components/dashboard-main.tsx
* @description 대시보드 메인 UI(검색/시세/차트)
*/
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
function formatPrice(value: number) {
return `${PRICE_FORMATTER.format(value)}`;
}
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 getMarketPhaseLabel(marketPhase: DashboardMarketPhase) {
return marketPhase === "regular" ? "장중(한국시간 09:00~15:30)" : "장외/휴장";
}
/**
* 주가 라인 차트(SVG)
*/
function StockLineChart({ candles }: { candles: StockCandlePoint[] }) {
const chart = (() => {
const width = 760;
const height = 280;
const paddingX = 24;
const paddingY = 20;
const plotWidth = width - paddingX * 2;
const plotHeight = height - paddingY * 2;
const prices = candles.map((item) => item.price);
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const range = Math.max(maxPrice - minPrice, 1);
const points = candles.map((item, index) => {
const x = paddingX + (index / Math.max(candles.length - 1, 1)) * plotWidth;
const y = paddingY + ((maxPrice - item.price) / range) * plotHeight;
return { x, y };
});
const linePoints = points.map((point) => `${point.x},${point.y}`).join(" ");
const firstPoint = points[0];
const lastPoint = points[points.length - 1];
const areaPoints = `${linePoints} ${lastPoint.x},${height - paddingY} ${firstPoint.x},${height - paddingY}`;
return { width, height, paddingX, paddingY, minPrice, maxPrice, linePoints, areaPoints };
})();
return (
<div className="h-[300px] w-full">
<svg viewBox={`0 0 ${chart.width} ${chart.height}`} className="h-full w-full">
<defs>
<linearGradient id="priceAreaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-brand-500)" stopOpacity="0.35" />
<stop offset="100%" stopColor="var(--color-brand-500)" stopOpacity="0.02" />
</linearGradient>
</defs>
<line
x1={chart.paddingX}
y1={chart.paddingY}
x2={chart.width - chart.paddingX}
y2={chart.paddingY}
stroke="currentColor"
className="text-border"
/>
<line
x1={chart.paddingX}
y1={chart.height / 2}
x2={chart.width - chart.paddingX}
y2={chart.height / 2}
stroke="currentColor"
className="text-border/70"
/>
<line
x1={chart.paddingX}
y1={chart.height - chart.paddingY}
x2={chart.width - chart.paddingX}
y2={chart.height - chart.paddingY}
stroke="currentColor"
className="text-border"
/>
<polygon points={chart.areaPoints} fill="url(#priceAreaGradient)" />
<polyline
points={chart.linePoints}
fill="none"
stroke="var(--color-brand-600)"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>{candles[0]?.time}</span>
<span> {formatPrice(chart.minPrice)}</span>
<span> {formatPrice(chart.maxPrice)}</span>
<span>{candles[candles.length - 1]?.time}</span>
</div>
</div>
);
}
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>
);
}
async function fetchStockSearch(keyword: string) {
const response = await fetch(`/api/kis/domestic/search?q=${encodeURIComponent(keyword)}`, {
cache: "no-store",
});
const payload = (await response.json()) as DashboardStockSearchResponse | { error?: string };
if (!response.ok) {
throw new Error("error" in payload ? payload.error : "종목 검색 중 오류가 발생했습니다.");
}
return payload as DashboardStockSearchResponse;
}
async function fetchStockOverview(symbol: string, credentials: KisRuntimeCredentials) {
const response = await fetch(`/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`, {
method: "GET",
headers: {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
},
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;
}
async function validateKisCredentials(credentials: KisRuntimeCredentials) {
const response = await fetch("/api/kis/validate", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as DashboardKisValidateResponse;
if (!response.ok || !payload.ok) {
throw new Error(payload.message || "API 키 검증에 실패했습니다.");
}
return payload;
}
/**
* KIS 접근토큰 폐기 요청
* @param credentials 검증 완료된 KIS 키
* @returns 폐기 응답
* @see app/api/kis/revoke/route.ts POST - revokeP 폐기 프록시
*/
async function revokeKisCredentials(credentials: KisRuntimeCredentials) {
const response = await fetch("/api/kis/revoke", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as DashboardKisRevokeResponse;
if (!response.ok || !payload.ok) {
throw new Error(payload.message || "API 키 접근 폐기에 실패했습니다.");
}
return payload;
}
const KIS_REALTIME_TR_ID_REAL = "H0UNCNT0";
const KIS_REALTIME_TR_ID_MOCK = "H0STCNT0";
function resolveRealtimeTrId(tradingEnv: KisRuntimeCredentials["tradingEnv"]) {
return tradingEnv === "mock" ? KIS_REALTIME_TR_ID_MOCK : KIS_REALTIME_TR_ID_REAL;
}
/**
* KIS 실시간 웹소켓 승인키를 발급받습니다.
* @param credentials 검증 완료된 KIS 키
* @returns approval key + ws url
* @see app/api/kis/ws/approval/route.ts POST - Approval 발급 프록시
*/
async function fetchKisWebSocketApproval(credentials: KisRuntimeCredentials) {
const response = await fetch("/api/kis/ws/approval", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as DashboardKisWsApprovalResponse;
if (!response.ok || !payload.ok || !payload.approvalKey || !payload.wsUrl) {
throw new Error(payload.message || "KIS 실시간 웹소켓 승인키 발급에 실패했습니다.");
}
return payload;
}
/**
* KIS 실시간 체결가 구독/해제 메시지를 생성합니다.
* @param approvalKey websocket 승인키
* @param symbol 종목코드
* @param trType "1"(구독) | "2"(해제)
* @returns websocket 요청 메시지
* @see https://github.com/koreainvestment/open-trading-api
*/
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,
},
},
};
}
interface KisRealtimeTick {
point: StockCandlePoint;
price: number;
accumulatedVolume: number;
tickTime: string;
}
/**
* KIS 실시간 체결가 원문을 차트 포인트로 변환합니다.
* @param raw websocket 수신 원문
* @param expectedSymbol 현재 선택 종목코드
* @returns 실시간 포인트 또는 null
*/
function parseKisRealtimeTick(raw: string, expectedSymbol: string, expectedTrId: string): KisRealtimeTick | null {
if (!/^([01])\|/.test(raw)) return null;
const parts = raw.split("|");
if (parts.length < 4) return null;
if (parts[1] !== expectedTrId) return null;
const tickCount = Number(parts[2] ?? "1");
const values = parts[3].split("^");
const isBatch = Number.isInteger(tickCount) && tickCount > 1 && values.length % tickCount === 0;
const fieldsPerTick = isBatch ? values.length / tickCount : values.length;
const baseIndex = isBatch ? (tickCount - 1) * fieldsPerTick : 0;
const symbol = values[baseIndex];
const hhmmss = values[baseIndex + 1];
const price = Number((values[baseIndex + 2] ?? "").replaceAll(",", "").trim());
const accumulatedVolume = Number((values[baseIndex + 13] ?? "").replaceAll(",", "").trim());
if (symbol !== expectedSymbol) return null;
if (!Number.isFinite(price) || price <= 0) return null;
return {
point: {
time: formatRealtimeTickTime(hhmmss),
price,
},
price,
accumulatedVolume: Number.isFinite(accumulatedVolume) && accumulatedVolume > 0 ? accumulatedVolume : 0,
tickTime: hhmmss ?? "",
};
}
function formatRealtimeTickTime(hhmmss?: string) {
if (!hhmmss || hhmmss.length !== 6) return "실시간";
return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`;
}
function appendRealtimeTick(prev: StockCandlePoint[], next: StockCandlePoint) {
if (prev.length === 0) return [next];
const last = prev[prev.length - 1];
if (last.time === next.time) {
return [...prev.slice(0, -1), next];
}
return [...prev, next].slice(-80);
}
function toTickOrderValue(hhmmss?: string) {
if (!hhmmss || !/^\d{6}$/.test(hhmmss)) return -1;
return Number(hhmmss);
}
/**
* 대시보드 메인 화면
*/
export function DashboardMain() {
// [State] KIS 키 입력/검증 상태(zustand + persist)
const {
kisTradingEnvInput,
kisAppKeyInput,
kisAppSecretInput,
verifiedCredentials,
isKisVerified,
tradingEnv,
setKisTradingEnvInput,
setKisAppKeyInput,
setKisAppSecretInput,
setVerifiedKisSession,
invalidateKisVerification,
clearKisRuntimeSession,
} = useKisRuntimeStore(
useShallow((state) => ({
kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput,
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
tradingEnv: state.tradingEnv,
setKisTradingEnvInput: state.setKisTradingEnvInput,
setKisAppKeyInput: state.setKisAppKeyInput,
setKisAppSecretInput: state.setKisAppSecretInput,
setVerifiedKisSession: state.setVerifiedKisSession,
invalidateKisVerification: state.invalidateKisVerification,
clearKisRuntimeSession: state.clearKisRuntimeSession,
})),
);
// [State] 검증 상태 메시지
const [kisStatusMessage, setKisStatusMessage] = useState<string | null>(null);
const [kisStatusError, setKisStatusError] = useState<string | null>(null);
// [State] 검색/선택 데이터
const [keyword, setKeyword] = useState("삼성전자");
const [searchResults, setSearchResults] = useState<DashboardStockSearchItem[]>([]);
const [selectedStock, setSelectedStock] = useState<DashboardStockItem | null>(null);
const [selectedOverviewMeta, setSelectedOverviewMeta] = useState<{
priceSource: DashboardPriceSource;
marketPhase: DashboardMarketPhase;
fetchedAt: string;
} | null>(null);
const [realtimeCandles, setRealtimeCandles] = useState<StockCandlePoint[]>([]);
const [isRealtimeConnected, setIsRealtimeConnected] = useState(false);
const [realtimeError, setRealtimeError] = useState<string | null>(null);
const [lastRealtimeTickAt, setLastRealtimeTickAt] = useState<number | null>(null);
const [realtimeTickCount, setRealtimeTickCount] = useState(0);
// [State] 영역별 에러
const [searchError, setSearchError] = useState<string | null>(null);
const [overviewError, setOverviewError] = useState<string | null>(null);
// [State] 비동기 전환 상태
const [isValidatingKis, startValidateTransition] = useTransition();
const [isRevokingKis, startRevokeTransition] = useTransition();
const [isSearching, startSearchTransition] = useTransition();
const [isLoadingOverview, startOverviewTransition] = useTransition();
const realtimeSocketRef = useRef<WebSocket | null>(null);
const realtimeApprovalKeyRef = useRef<string | null>(null);
const lastRealtimeTickOrderRef = useRef<number>(-1);
const isPositive = (selectedStock?.change ?? 0) >= 0;
const chartCandles =
isRealtimeConnected && realtimeCandles.length > 0 ? realtimeCandles : (selectedStock?.candles ?? []);
const apiPriceSourceLabel = selectedOverviewMeta
? getPriceSourceLabel(selectedOverviewMeta.priceSource, selectedOverviewMeta.marketPhase)
: null;
const realtimeTrId = verifiedCredentials ? resolveRealtimeTrId(verifiedCredentials.tradingEnv) : null;
const effectivePriceSourceLabel =
isRealtimeConnected && lastRealtimeTickAt
? `실시간 체결(WebSocket ${realtimeTrId ?? KIS_REALTIME_TR_ID_REAL})`
: apiPriceSourceLabel;
useEffect(() => {
setRealtimeCandles([]);
setIsRealtimeConnected(false);
setRealtimeError(null);
setLastRealtimeTickAt(null);
setRealtimeTickCount(0);
lastRealtimeTickOrderRef.current = -1;
}, [selectedStock?.symbol]);
useEffect(() => {
if (!isRealtimeConnected || lastRealtimeTickAt) return;
const noTickTimer = window.setTimeout(() => {
setRealtimeError("실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요.");
}, 8000);
return () => {
window.clearTimeout(noTickTimer);
};
}, [isRealtimeConnected, lastRealtimeTickAt]);
useEffect(() => {
const symbol = selectedStock?.symbol;
if (!symbol || !isKisVerified || !verifiedCredentials) {
setIsRealtimeConnected(false);
setRealtimeError(null);
setRealtimeTickCount(0);
lastRealtimeTickOrderRef.current = -1;
realtimeSocketRef.current?.close();
realtimeSocketRef.current = null;
realtimeApprovalKeyRef.current = null;
return;
}
let disposed = false;
let socket: WebSocket | null = null;
const realtimeTrId = resolveRealtimeTrId(verifiedCredentials.tradingEnv);
const connectKisRealtimePrice = async () => {
try {
setRealtimeError(null);
setIsRealtimeConnected(false);
const approval = await fetchKisWebSocketApproval(verifiedCredentials);
if (disposed) return;
realtimeApprovalKeyRef.current = approval.approvalKey ?? null;
socket = new WebSocket(`${approval.wsUrl}/tryitout`);
realtimeSocketRef.current = socket;
socket.onopen = () => {
if (disposed || !realtimeApprovalKeyRef.current) return;
const subscribeMessage = buildKisRealtimeMessage(realtimeApprovalKeyRef.current, symbol, realtimeTrId, "1");
socket?.send(JSON.stringify(subscribeMessage));
setIsRealtimeConnected(true);
};
socket.onmessage = (event) => {
if (disposed || typeof event.data !== "string") return;
const tick = parseKisRealtimeTick(event.data, symbol, realtimeTrId);
if (!tick) return;
// 지연 도착으로 시간이 역행하는 틱은 무시해 차트 흔들림을 줄입니다.
const nextTickOrder = toTickOrderValue(tick.tickTime);
if (nextTickOrder > 0 && lastRealtimeTickOrderRef.current > nextTickOrder) {
return;
}
if (nextTickOrder > 0) {
lastRealtimeTickOrderRef.current = nextTickOrder;
}
setRealtimeError(null);
setLastRealtimeTickAt(Date.now());
setRealtimeTickCount((prev) => prev + 1);
setRealtimeCandles((prev) => appendRealtimeTick(prev, tick.point));
// 실시간 체결가를 카드 현재가/등락/거래량에도 반영합니다.
setSelectedStock((prev) => {
if (!prev || prev.symbol !== symbol) return prev;
const nextPrice = tick.price;
const nextChange = nextPrice - prev.prevClose;
const nextChangeRate = prev.prevClose > 0 ? (nextChange / prev.prevClose) * 100 : prev.changeRate;
const nextHigh = prev.high > 0 ? Math.max(prev.high, nextPrice) : nextPrice;
const nextLow = prev.low > 0 ? Math.min(prev.low, nextPrice) : nextPrice;
return {
...prev,
currentPrice: nextPrice,
change: nextChange,
changeRate: nextChangeRate,
high: nextHigh,
low: nextLow,
volume: tick.accumulatedVolume > 0 ? tick.accumulatedVolume : prev.volume,
};
});
};
socket.onerror = () => {
if (disposed) return;
setIsRealtimeConnected(false);
setRealtimeError("실시간 연결 중 오류가 발생했습니다.");
};
socket.onclose = () => {
if (disposed) return;
setIsRealtimeConnected(false);
};
} catch (error) {
if (disposed) return;
const message =
error instanceof Error ? error.message : "실시간 웹소켓 초기화 중 오류가 발생했습니다.";
setRealtimeError(message);
setIsRealtimeConnected(false);
}
};
void connectKisRealtimePrice();
return () => {
disposed = true;
setIsRealtimeConnected(false);
const approvalKey = realtimeApprovalKeyRef.current;
if (socket?.readyState === WebSocket.OPEN && approvalKey) {
const unsubscribeMessage = buildKisRealtimeMessage(approvalKey, symbol, realtimeTrId, "2");
socket.send(JSON.stringify(unsubscribeMessage));
}
socket?.close();
if (realtimeSocketRef.current === socket) {
realtimeSocketRef.current = null;
}
realtimeApprovalKeyRef.current = null;
};
}, [
isKisVerified,
selectedStock?.symbol,
verifiedCredentials,
]);
const loadOverview = useCallback(
async (symbol: string, credentials: KisRuntimeCredentials) => {
try {
setOverviewError(null);
const data = await fetchStockOverview(symbol, credentials);
setSelectedStock(data.stock);
setSelectedOverviewMeta({
priceSource: data.priceSource,
marketPhase: data.marketPhase,
fetchedAt: data.fetchedAt,
});
} catch (error) {
const message = error instanceof Error ? error.message : "종목 조회 중 오류가 발생했습니다.";
setOverviewError(message);
setSelectedOverviewMeta(null);
}
},
[],
);
const loadSearch = useCallback(
async (nextKeyword: string, credentials: KisRuntimeCredentials, pickFirst = false) => {
try {
setSearchError(null);
const data = await fetchStockSearch(nextKeyword);
setSearchResults(data.items);
if (pickFirst && data.items[0]) {
await loadOverview(data.items[0].symbol, credentials);
}
} catch (error) {
const message = error instanceof Error ? error.message : "종목 검색 중 오류가 발생했습니다.";
setSearchError(message);
}
},
[loadOverview],
);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!isKisVerified || !verifiedCredentials) {
setSearchError("상단에서 API 키 검증을 먼저 완료해 주세요.");
return;
}
startSearchTransition(() => {
void loadSearch(keyword, verifiedCredentials, true);
});
}
function handlePickStock(item: DashboardStockSearchItem) {
if (!isKisVerified || !verifiedCredentials) {
setSearchError("상단에서 API 키 검증을 먼저 완료해 주세요.");
return;
}
setKeyword(item.name);
startOverviewTransition(() => {
void loadOverview(item.symbol, verifiedCredentials);
});
}
function handleValidateKis() {
startValidateTransition(() => {
void (async () => {
try {
setKisStatusError(null);
setKisStatusMessage(null);
const trimmedAppKey = kisAppKeyInput.trim();
const trimmedAppSecret = kisAppSecretInput.trim();
if (!trimmedAppKey || !trimmedAppSecret) {
throw new Error("앱 키와 앱 시크릿을 모두 입력해 주세요.");
}
const credentials: KisRuntimeCredentials = {
appKey: trimmedAppKey,
appSecret: trimmedAppSecret,
tradingEnv: kisTradingEnvInput,
};
const result = await validateKisCredentials(credentials);
setVerifiedKisSession(credentials, result.tradingEnv);
setKisStatusMessage(
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"} 모드)`,
);
startSearchTransition(() => {
void loadSearch(keyword || "삼성전자", credentials, true);
});
} catch (error) {
const message = error instanceof Error ? error.message : "API 키 검증 중 오류가 발생했습니다.";
invalidateKisVerification();
setSearchResults([]);
setSelectedStock(null);
setSelectedOverviewMeta(null);
setKisStatusError(message);
}
})();
});
}
function handleRevokeKis() {
if (!verifiedCredentials) {
setKisStatusError("먼저 API 키 검증을 완료해 주세요.");
return;
}
startRevokeTransition(() => {
void (async () => {
try {
// 접근 폐기 전, 화면 상태 메시지를 초기화합니다.
setKisStatusError(null);
setKisStatusMessage(null);
const result = await revokeKisCredentials(verifiedCredentials);
// 로그아웃처럼 검증/조회 상태를 초기화합니다.
clearKisRuntimeSession(result.tradingEnv);
setSearchResults([]);
setSelectedStock(null);
setSelectedOverviewMeta(null);
setSearchError(null);
setOverviewError(null);
setKisStatusMessage(`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"} 모드)`);
} catch (error) {
const message = error instanceof Error ? error.message : "API 키 접근 폐기 중 오류가 발생했습니다.";
setKisStatusError(message);
}
})();
});
}
return (
<div className="flex flex-col gap-6">
{/* ========== KIS KEY VERIFY SECTION ========== */}
<section>
<Card className="border-brand-200 bg-gradient-to-r from-brand-50/60 to-background">
<CardHeader>
<CardTitle>KIS API </CardTitle>
<CardDescription>
, API . .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-3">
<div className="md:col-span-1">
<label className="mb-1 block text-xs text-muted-foreground"> </label>
<div className="flex gap-2">
<Button
type="button"
variant={kisTradingEnvInput === "real" ? "default" : "outline"}
className={cn("flex-1", kisTradingEnvInput === "real" ? "bg-brand-600 hover:bg-brand-700" : "")}
onClick={() => setKisTradingEnvInput("real")}
>
</Button>
<Button
type="button"
variant={kisTradingEnvInput === "mock" ? "default" : "outline"}
className={cn("flex-1", kisTradingEnvInput === "mock" ? "bg-brand-600 hover:bg-brand-700" : "")}
onClick={() => setKisTradingEnvInput("mock")}
>
</Button>
</div>
</div>
<div className="md:col-span-1">
<label className="mb-1 block text-xs text-muted-foreground">KIS App Key</label>
<Input
type="password"
value={kisAppKeyInput}
onChange={(event) => setKisAppKeyInput(event.target.value)}
placeholder="앱 키 입력"
autoComplete="off"
/>
</div>
<div className="md:col-span-1">
<label className="mb-1 block text-xs text-muted-foreground">KIS App Secret</label>
<Input
type="password"
value={kisAppSecretInput}
onChange={(event) => setKisAppSecretInput(event.target.value)}
placeholder="앱 시크릿 입력"
autoComplete="off"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
onClick={handleValidateKis}
disabled={isValidatingKis || !kisAppKeyInput.trim() || !kisAppSecretInput.trim()}
className="bg-brand-600 hover:bg-brand-700"
>
{isValidatingKis ? "검증 중..." : "API 키 검증"}
</Button>
<Button
type="button"
variant="outline"
onClick={handleRevokeKis}
disabled={isRevokingKis || !isKisVerified || !verifiedCredentials}
className="border-brand-200 text-brand-700 hover:bg-brand-50 hover:text-brand-800"
>
{isRevokingKis ? "폐기 중..." : "접근 폐기"}
</Button>
{isKisVerified ? (
<span className="rounded-full bg-brand-100 px-3 py-1 text-xs font-semibold text-brand-700">
({tradingEnv === "real" ? "실전" : "모의"})
</span>
) : (
<span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground"></span>
)}
</div>
{kisStatusError ? <p className="text-sm text-red-600">{kisStatusError}</p> : null}
{kisStatusMessage ? <p className="text-sm text-brand-700">{kisStatusMessage}</p> : null}
<div className="rounded-lg border border-brand-200 bg-brand-50/70 px-3 py-2 text-xs text-brand-800">
API (zustand persist) , .
</div>
</CardContent>
</Card>
</section>
{/* ========== DASHBOARD TITLE SECTION ========== */}
<section>
<h2 className="text-3xl font-bold tracking-tight"> </h2>
<p className="mt-2 text-sm text-muted-foreground">
, , .
</p>
</section>
{/* ========== STOCK SEARCH SECTION ========== */}
<section>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> (: 삼성전자, 005930) .</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={handleSubmit} className="flex flex-col gap-3 md:flex-row">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="종목명 / 종목코드 검색"
className="pl-9"
disabled={!isKisVerified}
/>
</div>
<Button type="submit" className="md:min-w-28" disabled={!isKisVerified || isSearching || !keyword.trim()}>
{isSearching ? "검색 중..." : "검색"}
</Button>
</form>
{!isKisVerified ? (
<p className="text-xs text-muted-foreground"> API / .</p>
) : searchError ? (
<p className="text-sm text-red-600">{searchError}</p>
) : (
<p className="text-xs text-muted-foreground">
{searchResults.length > 0
? `검색 결과 ${searchResults.length}`
: "검색어를 입력하고 엔터를 누르면 종목이 표시됩니다."}
</p>
)}
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-5">
{searchResults.map((item) => {
const active = item.symbol === selectedStock?.symbol;
return (
<button
key={`${item.symbol}-${item.market}`}
type="button"
onClick={() => handlePickStock(item)}
className={cn(
"rounded-lg border px-3 py-2 text-left transition-colors",
active
? "border-brand-500 bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300"
: "border-border bg-background hover:bg-muted/60",
)}
disabled={!isKisVerified}
>
<p className="text-sm font-semibold">{item.name}</p>
<p className="text-xs text-muted-foreground">
{item.symbol} · {item.market}
</p>
</button>
);
})}
</div>
</CardContent>
</Card>
</section>
{/* ========== STOCK OVERVIEW SECTION ========== */}
<section className="grid gap-4 xl:grid-cols-3">
<Card className="xl:col-span-2">
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-xl">{selectedStock?.name ?? "종목을 선택해 주세요"}</CardTitle>
<CardDescription>
{selectedStock ? `${selectedStock.symbol} · ${selectedStock.market}` : "선택된 종목이 없습니다."}
</CardDescription>
</div>
{selectedStock && (
<div
className={cn(
"inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm font-semibold",
isPositive
? "bg-brand-50 text-brand-700 dark:bg-brand-900/35 dark:text-brand-300"
: "bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300",
)}
>
{isPositive ? <TrendingUp className="h-4 w-4" /> : <TrendingDown className="h-4 w-4" />}
{isPositive ? "+" : ""}
{selectedStock.change.toLocaleString()} ({isPositive ? "+" : ""}
{selectedStock.changeRate.toFixed(2)}%)
</div>
)}
</CardHeader>
<CardContent>
{overviewError ? (
<p className="text-sm text-red-600">{overviewError}</p>
) : !isKisVerified ? (
<p className="text-sm text-muted-foreground"> API .</p>
) : isLoadingOverview && !selectedStock ? (
<p className="text-sm text-muted-foreground"> ...</p>
) : selectedStock ? (
<>
<p className="mb-4 text-3xl font-extrabold tracking-tight">{formatPrice(selectedStock.currentPrice)}</p>
{effectivePriceSourceLabel && selectedOverviewMeta ? (
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-1 text-brand-700">
: {effectivePriceSourceLabel}
</span>
<span className="rounded-full border border-border px-2 py-1">
: {getMarketPhaseLabel(selectedOverviewMeta.marketPhase)}
</span>
<span className="rounded-full border border-border px-2 py-1">
: {new Date(selectedOverviewMeta.fetchedAt).toLocaleTimeString("ko-KR")}
</span>
</div>
) : null}
<StockLineChart candles={chartCandles} />
{realtimeError ? <p className="mt-3 text-xs text-red-600">{realtimeError}</p> : null}
</>
) : (
<p className="text-sm text-muted-foreground"> .</p>
)}
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
<PriceStat label="시가" value={formatPrice(selectedStock?.open ?? 0)} />
<PriceStat label="고가" value={formatPrice(selectedStock?.high ?? 0)} />
<PriceStat label="저가" value={formatPrice(selectedStock?.low ?? 0)} />
<PriceStat label="전일 종가" value={formatPrice(selectedStock?.prevClose ?? 0)} />
<PriceStat label="누적 거래량" value={formatVolume(selectedStock?.volume ?? 0)} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-brand-500" />
<p> {tradingEnv === "real" ? "실전" : "모의"} API </p>
</div>
<div className="flex items-center gap-2">
<Activity className={cn("h-4 w-4", isRealtimeConnected ? "text-brand-500" : "text-muted-foreground")} />
<p>
:{" "}
{isRealtimeConnected ? "연결됨 (WebSocket)" : "대기 중 (일봉 차트 표시)"}
</p>
</div>
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<p> : {lastRealtimeTickAt ? new Date(lastRealtimeTickAt).toLocaleTimeString("ko-KR") : "수신 전"}</p>
</div>
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<p> : {realtimeTickCount.toLocaleString("ko-KR")}</p>
</div>
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-brand-500" />
<p> 단계: 주문/ API </p>
</div>
</CardContent>
</Card>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { Activity, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
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-green-100 px-1.5 py-0.5 text-xs font-medium text-green-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>
);
}

View File

@@ -0,0 +1,48 @@
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-blue-500";
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>
);
}

View File

@@ -0,0 +1,71 @@
// 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-500"
: "text-foreground";
return (
<div className="flex items-center justify-between px-4 py-3">
{/* Left: Stock Info */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold">{stock.name}</h1>
<span className="text-sm text-muted-foreground">
{stock.symbol}/{stock.market}
</span>
</div>
<Separator orientation="vertical" className="h-6" />
<div className={cn("flex items-end gap-2", colorClass)}>
<span className="text-2xl font-bold tracking-tight">{price}</span>
<span className="text-sm font-medium mb-1">
{changeRate}% <span className="text-xs ml-1">{change}</span>
</span>
</div>
</div>
{/* Right: 24h Stats */}
<div className="hidden md:flex items-center gap-6 text-sm">
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs"></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"></span>
<span className="font-medium text-blue-500">{low || "--"}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs">(24H)</span>
<span className="font-medium">{volume || "--"}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
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 h-[calc(100vh-64px)] flex-col overflow-hidden",
className,
)}
>
{/* 1. Header Area */}
<div className="flex-none border-b border-border bg-background">
{header}
</div>
{/* 2. Main Content Area */}
<div className="flex flex-1 flex-col overflow-hidden xl:flex-row">
{/* Left Column: Chart & Info */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col border-border 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 xl:w-[460px] 2xl:w-[500px]">
{/* Top: Order Book (Hoga) */}
<div className="min-h-[360px] flex-1 overflow-hidden border-t border-border xl:min-h-0 xl:border-t-0 xl:border-b">
{orderBook}
</div>
{/* Bottom: Order Form */}
<div className="flex-none h-[320px] sm:h-[360px] xl:h-[380px]">
{orderForm}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,249 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
DashboardStockItem,
DashboardOrderSide,
} from "@/features/dashboard/types/dashboard.types";
import { useOrder } from "@/features/dashboard/hooks/useOrder";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import { Loader2 } from "lucide-react";
interface OrderFormProps {
stock?: DashboardStockItem;
}
export function OrderForm({ stock }: OrderFormProps) {
const verifiedCredentials = useKisRuntimeStore(
(state) => state.verifiedCredentials,
);
const { placeOrder, isLoading, error } = useOrder();
// Form State
// Initial price set from stock current price if available, relying on component remount (key) for updates
const [price, setPrice] = useState<string>(
stock?.currentPrice.toString() || "",
);
const [quantity, setQuantity] = useState<string>("");
const [activeTab, setActiveTab] = useState("buy");
const handleOrder = async (side: DashboardOrderSide) => {
if (!stock || !verifiedCredentials) return;
const priceNum = parseInt(price.replace(/,/g, ""), 10);
const qtyNum = parseInt(quantity.replace(/,/g, ""), 10);
if (isNaN(priceNum) || priceNum <= 0) {
alert("가격을 올바르게 입력해주세요.");
return;
}
if (isNaN(qtyNum) || qtyNum <= 0) {
alert("수량을 올바르게 입력해주세요.");
return;
}
if (!verifiedCredentials.accountNo) {
alert(
"계좌번호가 설정되지 않았습니다. 설정에서 계좌번호를 입력해주세요.",
);
return;
}
const response = await placeOrder(
{
symbol: stock.symbol,
side: side,
orderType: "limit", // 지정가 고정
price: priceNum,
quantity: qtyNum,
accountNo: verifiedCredentials.accountNo,
accountProductCode: "01", // Default to '01' (위탁)
},
verifiedCredentials,
);
if (response && response.orderNo) {
alert(`주문 전송 완료! 주문번호: ${response.orderNo}`);
setQuantity("");
}
};
const totalPrice =
parseInt(price.replace(/,/g, "") || "0", 10) *
parseInt(quantity.replace(/,/g, "") || "0", 10);
const setPercent = (pct: string) => {
// Placeholder logic for percent click
console.log("Percent clicked:", pct);
};
const isMarketDataAvailable = !!stock;
return (
<div className="h-full bg-background p-4 border-l border-border">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full h-full flex flex-col"
>
<TabsList className="grid w-full grid-cols-2 mb-4">
<TabsTrigger
value="buy"
className="data-[state=active]:bg-red-600 data-[state=active]:text-white transition-colors"
>
</TabsTrigger>
<TabsTrigger
value="sell"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white transition-colors"
>
</TabsTrigger>
</TabsList>
<TabsContent
value="buy"
className="flex-1 flex flex-col space-y-4 data-[state=inactive]:hidden"
>
<OrderInputs
type="buy"
price={price}
setPrice={setPrice}
quantity={quantity}
setQuantity={setQuantity}
totalPrice={totalPrice}
disabled={!isMarketDataAvailable}
hasError={!!error}
errorMessage={error}
/>
<PercentButtons onSelect={setPercent} />
<Button
className="w-full bg-red-600 hover:bg-red-700 mt-auto text-lg h-12"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("buy")}
>
{isLoading ? <Loader2 className="animate-spin mr-2" /> : "매수하기"}
</Button>
</TabsContent>
<TabsContent
value="sell"
className="flex-1 flex flex-col space-y-4 data-[state=inactive]:hidden"
>
<OrderInputs
type="sell"
price={price}
setPrice={setPrice}
quantity={quantity}
setQuantity={setQuantity}
totalPrice={totalPrice}
disabled={!isMarketDataAvailable}
hasError={!!error}
errorMessage={error}
/>
<PercentButtons onSelect={setPercent} />
<Button
className="w-full bg-blue-600 hover:bg-blue-700 mt-auto text-lg h-12"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("sell")}
>
{isLoading ? <Loader2 className="animate-spin mr-2" /> : "매도하기"}
</Button>
</TabsContent>
</Tabs>
</div>
);
}
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-4">
<div className="flex justify-between text-xs text-muted-foreground">
<span></span>
<span>- {type === "buy" ? "KRW" : "주"}</span>
</div>
{hasError && (
<div className="p-2 bg-destructive/10 text-destructive text-xs rounded break-keep">
{errorMessage}
</div>
)}
<div className="grid grid-cols-4 gap-2 items-center">
<span className="text-sm font-medium">
{type === "buy" ? "매수가격" : "매도가격"}
</span>
<Input
className="col-span-3 text-right font-mono"
placeholder="0"
value={price}
onChange={(e) => setPrice(e.target.value)}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-4 gap-2 items-center">
<span className="text-sm font-medium"></span>
<Input
className="col-span-3 text-right font-mono"
placeholder="0"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-4 gap-2 items-center">
<span className="text-sm font-medium"></span>
<Input
className="col-span-3 text-right font-mono bg-muted/50"
value={totalPrice.toLocaleString()}
readOnly
disabled={disabled}
/>
</div>
</div>
);
}
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
return (
<div className="grid grid-cols-4 gap-2 mt-2">
{["10%", "25%", "50%", "100%"].map((pct) => (
<Button
key={pct}
variant="outline"
size="sm"
className="text-xs"
onClick={() => onSelect(pct)}
>
{pct}
</Button>
))}
</div>
);
}

View File

@@ -0,0 +1,80 @@
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; // 값이 증가하면 빨강, 감소하면 파랑 배경 깜빡임 여부
}
export function AnimatedQuantity({
value,
format = (v) => v.toLocaleString(),
className,
useColor = false,
}: AnimatedQuantityProps) {
const prevValueRef = useRef(value);
const [diff, setDiff] = useState<number | null>(null);
const [flash, setFlash] = useState<"up" | "down" | null>(null);
useEffect(() => {
if (prevValueRef.current !== value) {
const difference = value - prevValueRef.current;
setDiff(difference);
prevValueRef.current = value;
if (difference !== 0) {
setFlash(difference > 0 ? "up" : "down");
const timer = setTimeout(() => {
setDiff(null);
setFlash(null);
}, 1200); // 1.2초 후 초기화
return () => clearTimeout(timer);
}
}
}, [value]);
return (
<div className={cn("relative inline-block tabular-nums", className)}>
{/* Background Flash Effect */}
<AnimatePresence>
{useColor && flash && (
<motion.div
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>
{/* Main Value */}
<span className="relative z-10">{format(value)}</span>
{/* Diff Indicator */}
<AnimatePresence>
{diff !== null && diff !== 0 && (
<motion.span
initial={{ opacity: 1, y: 0, scale: 1 }}
animate={{ opacity: 0, y: -10, scale: 0.9 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.2, ease: "easeOut" }}
className={cn(
"absolute left-full top-0 ml-1 whitespace-nowrap text-[10px] font-bold z-20",
diff > 0 ? "text-red-500" : "text-blue-500",
)}
style={{ pointerEvents: "none" }} // 클릭 방해 방지
>
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
</motion.span>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,572 @@
import { useEffect, useMemo, useRef } 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;
// 체결가 행 중앙 스크롤
const centerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
centerRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
}, [latestPrice]);
// ─── 빈/로딩 상태 ───
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-background">
<Tabs defaultValue="normal" className="h-full min-h-0">
{/* 탭 헤더 */}
<div className="border-b px-2 pt-2">
<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="grid h-full min-h-0 grid-rows-[1fr_190px] overflow-hidden border-t">
<div className="grid min-h-0 grid-cols-[minmax(0,1fr)_150px] overflow-hidden">
{/* 호가 테이블 */}
<div className="min-h-0 border-r">
<BookHeader />
<ScrollArea className="h-[calc(100%-32px)]">
{/* 매도호가 */}
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
{/* 중앙 바: 현재 체결가 */}
<div
ref={centerRef}
className="grid h-9 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30"
>
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground">
{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-500",
)}
>
{fmtPct(pctChange(latestPrice, basePrice))}
</span>
)}
</div>
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground">
{totalBid > 0 ? fmt(totalBid) : ""}
</div>
</div>
{/* 매수호가 */}
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
</ScrollArea>
</div>
{/* 우측 요약 패널 */}
<SummaryPanel
orderBook={orderBook}
latestTick={latestTick}
spread={spread}
imbalance={imbalance}
totalAsk={totalAsk}
totalBid={totalBid}
/>
</div>
{/* 체결 목록 */}
<TradeTape ticks={recentTicks} />
</div>
</TabsContent>
{/* ── 누적호가 탭 ── */}
<TabsContent value="cumulative" className="min-h-0 flex-1">
<ScrollArea className="h-full border-t">
<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">
.
</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">
<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={isAsk ? "bg-red-50/15" : "bg-blue-50/15"}>
{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-8 grid-cols-3 border-b border-border/40 text-xs",
row.isHighlighted &&
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30",
)}
>
{/* 매도잔량 (좌측) */}
<div className="relative flex items-center justify-end px-2">
{isAsk && (
<>
<DepthBar ratio={ratio} side="ask" />
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
className="relative z-10 w-full text-right"
/>
</>
)}
</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-900/25",
)}
>
<span className={isAsk ? "text-red-600" : "text-blue-600"}>
{row.price > 0 ? fmt(row.price) : "-"}
</span>
<span
className={cn(
"text-[10px]",
row.changePercent !== null
? row.changePercent >= 0
? "text-red-500"
: "text-blue-500"
: "text-muted-foreground",
)}
>
{row.changePercent === null ? "-" : fmtPct(row.changePercent)}
</span>
</div>
{/* 매수잔량 (우측) */}
<div className="relative flex items-center justify-start px-2">
{!isAsk && (
<>
<DepthBar ratio={ratio} side="bid" />
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
className="relative z-10 w-full text-left"
/>
</>
)}
</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="border-l bg-muted/15 p-2 text-[11px]">
<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 rounded border bg-background px-2 py-1">
<span className="text-muted-foreground">{label}</span>
<span
className={cn(
"font-medium tabular-nums",
tone === "ask" && "text-red-600",
tone === "bid" && "text-blue-600",
)}
>
{value}
</span>
</div>
);
}
/** 잔량 깊이 바 */
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
if (ratio <= 0) return null;
return (
<div
className={cn(
"absolute inset-y-1 z-0 rounded-sm",
side === "ask" ? "right-1 bg-red-200/50" : "left-1 bg-blue-200/50",
)}
style={{ width: `${ratio}%` }}
/>
);
}
/** 체결 목록 (Trade Tape) */
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
return (
<div className="border-t bg-background">
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground">
<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">
.
</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"
>
<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">
{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(() => {
let askAcc = 0;
let bidAcc = 0;
return Array.from(
{ length: Math.max(asks.length, bids.length) },
(_, i) => {
askAcc += asks[i]?.size ?? 0;
bidAcc += bids[i]?.size ?? 0;
return { askAcc, bidAcc, price: asks[i]?.price || bids[i]?.price || 0 };
},
);
}, [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"
>
<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">
{fmt(r.bidAcc)}
</span>
</div>
))}
</div>
);
}
/** 로딩 스켈레톤 */
function OrderBookSkeleton() {
return (
<div className="flex h-full flex-col p-3">
<div className="mb-3 grid grid-cols-3 gap-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2">
{Array.from({ length: 16 }).map((_, i) => (
<Skeleton key={i} className="h-7 w-full" />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import type { FormEvent } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
interface StockSearchFormProps {
keyword: string;
onKeywordChange: (value: string) => void;
onSubmit: (e: FormEvent) => void;
disabled?: boolean;
isLoading?: boolean;
}
export function StockSearchForm({
keyword,
onKeywordChange,
onSubmit,
disabled,
isLoading,
}: StockSearchFormProps) {
return (
<form onSubmit={onSubmit} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="종목명 또는 코드(6자리) 입력..."
className="pl-9"
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
/>
</div>
<Button type="submit" disabled={disabled || isLoading}>
{isLoading ? "검색 중..." : "검색"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,47 @@
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>
);
}

View File

@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from "react";
import {
type KisRuntimeCredentials,
useKisRuntimeStore,
} from "@/features/dashboard/store/use-kis-runtime-store";
import type { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
import {
buildKisRealtimeMessage,
parseKisRealtimeOrderbook,
} from "@/features/dashboard/utils/kis-realtime.utils";
const KRX_ORDERBOOK_TR_ID = "H0STASP0";
const KRX_OVERTIME_ORDERBOOK_TR_ID = "H0STOAA0";
const DEFAULT_ORDERBOOK_TR_ID = "H0UNASP0";
/**
* @description 한국 시간 기준으로 시간외 단일가 구간(16:00~18:00)인지 확인합니다.
* @see resolveOrderBookTrId 시간대별 TR ID 선택
*/
function isKrxOvertimeInKst(now = new Date()) {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Seoul",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const parts = formatter.formatToParts(now);
const partMap = new Map(parts.map((part) => [part.type, part.value]));
const weekday = partMap.get("weekday");
if (weekday === "Sat" || weekday === "Sun") {
return false;
}
const hour = Number(partMap.get("hour") ?? "0");
const minute = Number(partMap.get("minute") ?? "0");
const totalMinutes = hour * 60 + minute;
return totalMinutes >= 16 * 60 && totalMinutes < 18 * 60;
}
/**
* @description 시장/시간대에 맞는 국내주식 호가 TR ID를 선택합니다.
* @see .tmp/open-trading-api/examples_user/domestic_stock/domestic_stock_functions_ws.py
*/
function resolveOrderBookTrId(market: "KOSPI" | "KOSDAQ" | undefined) {
if (market === "KOSPI" || market === "KOSDAQ") {
return isKrxOvertimeInKst()
? KRX_OVERTIME_ORDERBOOK_TR_ID
: KRX_ORDERBOOK_TR_ID;
}
return DEFAULT_ORDERBOOK_TR_ID;
}
/**
* @description KIS 실시간 호가 웹소켓 훅
* @see parseKisRealtimeOrderbook 웹소켓 payload 파싱
*/
export function useKisOrderbookWebSocket(
symbol: string | undefined,
market: "KOSPI" | "KOSDAQ" | undefined,
credentials: KisRuntimeCredentials | null,
isVerified: boolean,
) {
const [realtimeOrderBook, setRealtimeOrderBook] =
useState<DashboardStockOrderBookResponse | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const approvalKeyRef = useRef<string | null>(null);
const trId = resolveOrderBookTrId(market);
useEffect(() => {
setRealtimeOrderBook(null);
setError(null);
if (!symbol || !isVerified || !credentials) {
socketRef.current?.close();
socketRef.current = null;
approvalKeyRef.current = null;
return;
}
let disposed = false;
let socket: WebSocket | null = null;
const connect = async () => {
try {
console.log("[OrderBook WS] 연결 시작", { symbol, trId, market });
const approvalKey = await useKisRuntimeStore
.getState()
.getOrFetchApprovalKey();
if (!approvalKey) {
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
}
if (disposed) return;
approvalKeyRef.current = approvalKey;
const wsBaseUrl =
process.env.NEXT_PUBLIC_KIS_WS_URL ||
"ws://ops.koreainvestment.com:21000";
const wsUrl = `${wsBaseUrl}/tryitout/${trId}`;
socket = new WebSocket(wsUrl);
socketRef.current = socket;
socket.onopen = () => {
if (disposed || !approvalKeyRef.current) return;
const subscribeMessage = buildKisRealtimeMessage(
approvalKeyRef.current,
symbol,
trId,
"1",
);
socket?.send(JSON.stringify(subscribeMessage));
setIsConnected(true);
};
socket.onmessage = (event) => {
if (disposed || typeof event.data !== "string") return;
const orderBook = parseKisRealtimeOrderbook(event.data, symbol);
if (!orderBook) {
return;
}
orderBook.tradingEnv = credentials.tradingEnv;
setRealtimeOrderBook(orderBook);
};
socket.onerror = () => {
if (disposed) return;
setIsConnected(false);
};
socket.onclose = () => {
if (disposed) return;
setIsConnected(false);
};
} catch (err) {
if (disposed) return;
setError(
err instanceof Error
? err.message
: "실시간 호가 웹소켓 초기화 중 오류가 발생했습니다.",
);
setIsConnected(false);
}
};
void connect();
return () => {
disposed = true;
setIsConnected(false);
const approvalKey = approvalKeyRef.current;
if (socket?.readyState === WebSocket.OPEN && approvalKey) {
const unsubscribeMessage = buildKisRealtimeMessage(
approvalKey,
symbol,
trId,
"2",
);
socket.send(JSON.stringify(unsubscribeMessage));
}
socket?.close();
if (socketRef.current === socket) {
socketRef.current = null;
}
approvalKeyRef.current = null;
};
}, [isVerified, symbol, market, credentials, trId]);
return {
realtimeOrderBook,
isConnected,
error,
};
}

View File

@@ -0,0 +1,348 @@
import { useEffect, useRef, useState } from "react";
import {
type KisRuntimeCredentials,
useKisRuntimeStore,
} from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
import {
appendRealtimeTick,
buildKisRealtimeMessage,
parseKisRealtimeOrderbook,
parseKisRealtimeTickBatch,
toTickOrderValue,
} from "@/features/dashboard/utils/kis-realtime.utils";
const KIS_REALTIME_TR_ID_REAL = "H0STCNT0";
const KIS_REALTIME_TR_ID_OVERTIME = "H0STOUP0"; // 시간외 단일가
const KIS_REALTIME_TR_ID_MOCK = "H0STCNT0";
/** 호가 TR ID (정규장 / 시간외) */
const ORDERBOOK_TR_ID_KRX = "H0STASP0";
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0";
function resolveOrderBookTrId() {
const now = new Date();
const h = now.getHours();
const m = now.getMinutes();
const t = h * 100 + m;
return t >= 1600 && t < 1800 ? ORDERBOOK_TR_ID_OVERTIME : ORDERBOOK_TR_ID_KRX;
}
const MAX_TRADE_TICKS = 10; // 체결 테이프용 최대 개수
function resolveRealtimeTrId(tradingEnv: KisRuntimeCredentials["tradingEnv"]) {
if (tradingEnv === "mock") return KIS_REALTIME_TR_ID_MOCK;
// 현재 시간(KST) 체크 - 사용자 브라우저 시간 기준
const now = new Date();
const hours = now.getHours();
const minutes = now.getMinutes();
const time = hours * 100 + minutes;
// 16:00 ~ 18:00 사이는 시간외 단일가 TR ID 사용
if (time >= 1600 && time < 1800) {
return KIS_REALTIME_TR_ID_OVERTIME;
}
return KIS_REALTIME_TR_ID_REAL;
}
/**
* @description 통합 실시간 체결 웹소켓 훅 (H0STCNT0)
* - StockHeader: 실시간 현재가, 등락률, 시가/고가/저가/거래량
* - StockLineChart: 실시간 캔들 (분봉/일봉 등)
* - OrderBook (TradeTape): 최근 체결 내역 리스트
*/
export function useKisTradeWebSocket(
symbol: string | undefined,
credentials: KisRuntimeCredentials | null,
isVerified: boolean,
onTick?: (tick: DashboardRealtimeTradeTick) => void,
options?: {
/** 호가도 같은 WS에서 구독 (KIS 동시 연결 제한 우회) */
orderBookSymbol?: string;
orderBookMarket?: "KOSPI" | "KOSDAQ";
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
},
) {
// 1. StockHeader용 최신 데이터
const [latestTick, setLatestTick] =
useState<DashboardRealtimeTradeTick | null>(null);
// 2. StockLineChart용 캔들 데이터
const [realtimeCandles, setRealtimeCandles] = useState<StockCandlePoint[]>(
[],
);
// 3. TradeTape용 최근 체결 리스트
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 socketRef = useRef<WebSocket | null>(null);
const approvalKeyRef = useRef<string | null>(null);
const lastTickOrderRef = useRef<number>(-1);
const seenTickRef = useRef<Set<string>>(new Set());
const realtimeTrId = credentials
? resolveRealtimeTrId(credentials.tradingEnv)
: null;
// 데이터 없음 감지 (8초)
useEffect(() => {
if (!isConnected || lastTickAt) return;
const noTickTimer = window.setTimeout(() => {
setError(
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요.",
);
}, 8000);
return () => {
window.clearTimeout(noTickTimer);
};
}, [isConnected, lastTickAt]);
const obSymbol = options?.orderBookSymbol;
const onOrderBookMsg = options?.onOrderBookMessage;
const obTrId = obSymbol ? resolveOrderBookTrId() : null;
// 웹소켓 연결 로직
useEffect(() => {
// 초기화
setLatestTick(null);
setRealtimeCandles([]);
setRecentTradeTicks([]);
setError(null);
seenTickRef.current.clear();
if (!symbol || !isVerified || !credentials) {
if (socketRef.current) {
socketRef.current.close();
socketRef.current = null;
}
approvalKeyRef.current = null;
setIsConnected(false);
return;
}
let disposed = false;
let socket: WebSocket | null = null;
const trId = resolveRealtimeTrId(credentials.tradingEnv);
const connect = async () => {
try {
setError(null);
setIsConnected(false);
const approvalKey = await useKisRuntimeStore
.getState()
.getOrFetchApprovalKey();
if (!approvalKey) {
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
}
if (disposed) return;
approvalKeyRef.current = approvalKey;
const wsBaseUrl =
process.env.NEXT_PUBLIC_KIS_WS_URL ||
"ws://ops.koreainvestment.com:21000";
socket = new WebSocket(`${wsBaseUrl}/tryitout/${trId}`);
socketRef.current = socket;
console.log("[WS URL]", `${wsBaseUrl}/tryitout/${trId}`);
socket.onopen = () => {
if (disposed || !approvalKeyRef.current) return;
// 체결 구독
const subscribeMessage = buildKisRealtimeMessage(
approvalKeyRef.current,
symbol,
trId,
"1",
);
socket?.send(JSON.stringify(subscribeMessage));
// 호가 구독 (같은 소켓에서)
if (obSymbol && obTrId && approvalKeyRef.current) {
const obSubscribe = buildKisRealtimeMessage(
approvalKeyRef.current,
obSymbol,
obTrId,
"1",
);
socket?.send(JSON.stringify(obSubscribe));
}
setIsConnected(true);
};
socket.onmessage = (event) => {
if (disposed || typeof event.data !== "string") return;
// 호가 메시지인지 먼저 확인 (TR ID: H0STASP0 / H0STOAA0)
if (obSymbol && onOrderBookMsg) {
const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol);
if (orderBook) {
if (credentials) {
orderBook.tradingEnv = credentials.tradingEnv;
}
onOrderBookMsg(orderBook);
return;
}
}
const parsedTicks = parseKisRealtimeTickBatch(event.data, symbol);
if (parsedTicks.length === 0) {
// 구독 확인 JSON 메시지 등은 무시
return;
}
// 1. 데이터 정제 및 중복 제거 (TradeTape용)
const meaningfulTicks = parsedTicks.filter(
(tick) => tick.tradeVolume > 0,
);
const dedupedForTape = meaningfulTicks.filter((tick) => {
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
if (seenTickRef.current.has(key)) return false;
seenTickRef.current.add(key);
return true;
});
// 2. 최신 틱 업데이트 (StockHeader용)
// 배치 중 가장 마지막(최신) 틱을 사용
const lastTickInBatch = parsedTicks[parsedTicks.length - 1];
setLatestTick(lastTickInBatch);
// 3. 캔들 업데이트 (StockLineChart용)
// 지연 도착 틱 필터링
const nextTickOrder = toTickOrderValue(lastTickInBatch.tickTime);
if (nextTickOrder > 0) {
if (lastTickOrderRef.current <= nextTickOrder) {
lastTickOrderRef.current = nextTickOrder;
const candlePoint: StockCandlePoint = {
time: formatTime(lastTickInBatch.tickTime),
price: lastTickInBatch.price,
// 필요한 경우 open, high, low, volume 등을 여기서 조합 가능
// 현재 차트 컴포넌트는 Point 단위로 price만 주로 씀
};
setRealtimeCandles((prev) =>
appendRealtimeTick(prev, candlePoint),
);
}
}
// 4. 체결 테이프 업데이트
if (dedupedForTape.length > 0) {
setRecentTradeTicks((prev) =>
[...dedupedForTape.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
);
}
// 5. 콜백 및 상태 업데이트
setError(null);
setLastTickAt(Date.now());
// onTick 콜백용 데이터 구성
if (onTick) {
onTick(lastTickInBatch);
}
};
socket.onerror = () => {
if (disposed) return;
setIsConnected(false);
// setError("실시간 체결 연결 중 오류가 발생했습니다.");
};
socket.onclose = () => {
if (disposed) return;
setIsConnected(false);
};
} catch (err) {
if (disposed) return;
const message =
err instanceof Error
? err.message
: "실시간 웹소켓 초기화 중 오류가 발생했습니다.";
setError(message);
setIsConnected(false);
}
};
void connect();
const seenTickRefCurrent = seenTickRef.current;
return () => {
disposed = true;
setIsConnected(false);
const approvalKey = approvalKeyRef.current;
if (socket?.readyState === WebSocket.OPEN && approvalKey) {
// 체결 구독 해제
const unsubscribeMessage = buildKisRealtimeMessage(
approvalKey,
symbol,
trId,
"2",
);
socket.send(JSON.stringify(unsubscribeMessage));
// 호가 구독 해제
if (obSymbol && obTrId) {
const obUnsubscribe = buildKisRealtimeMessage(
approvalKey,
obSymbol,
obTrId,
"2",
);
socket.send(JSON.stringify(obUnsubscribe));
}
}
socket?.close();
if (socketRef.current === socket) {
socketRef.current = null;
}
approvalKeyRef.current = null;
seenTickRefCurrent.clear();
};
}, [
isVerified,
symbol,
credentials,
onTick,
obSymbol,
obTrId,
onOrderBookMsg,
]);
return {
latestTick, // Header용
realtimeCandles, // Chart용
recentTradeTicks, // Tape용
isConnected,
error,
lastTickAt,
realtimeTrId: realtimeTrId ?? KIS_REALTIME_TR_ID_REAL,
};
}
function formatTime(hhmmss: string) {
if (!hhmmss || hhmmss.length !== 6) return "실시간";
return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`;
}

View File

@@ -0,0 +1,61 @@
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,
};
}

View File

@@ -0,0 +1,91 @@
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,
};
}

View File

@@ -0,0 +1,118 @@
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);
}
});
},
[],
);
// 실시간 체결 데이터 수신 시 헤더/차트 기준 가격을 갱신합니다.
const updateRealtimeTradeTick = useCallback(
(tick: DashboardRealtimeTradeTick) => {
setSelectedStock((prev) => {
if (!prev) return prev;
const { price, accumulatedVolume, change, changeRate, tickTime } = tick;
const pointTime =
tickTime && tickTime.length === 6
? `${tickTime.slice(0, 2)}:${tickTime.slice(2, 4)}`
: "실시간";
const nextChange = change;
const nextChangeRate = Number.isFinite(changeRate)
? changeRate
: prev.prevClose > 0
? (nextChange / prev.prevClose) * 100
: prev.changeRate;
const nextHigh = prev.high > 0 ? Math.max(prev.high, price) : price;
const nextLow = prev.low > 0 ? Math.min(prev.low, price) : price;
const nextCandles =
prev.candles.length > 0 &&
prev.candles[prev.candles.length - 1]?.time === pointTime
? [
...prev.candles.slice(0, -1),
{
...prev.candles[prev.candles.length - 1],
time: pointTime,
price,
},
]
: [...prev.candles, { time: pointTime, price }].slice(-80);
return {
...prev,
currentPrice: price,
change: nextChange,
changeRate: nextChangeRate,
high: nextHigh,
low: nextLow,
volume: accumulatedVolume > 0 ? accumulatedVolume : prev.volume,
candles: nextCandles,
};
});
},
[],
);
return {
selectedStock,
setSelectedStock,
meta,
setMeta,
error,
setError,
isLoading,
loadOverview,
updateRealtimeTradeTick,
};
}

View File

@@ -0,0 +1,91 @@
import { useCallback, useRef, useState } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type { DashboardStockSearchItem } from "@/features/dashboard/types/dashboard.types";
import { fetchStockSearch } from "@/features/dashboard/apis/kis-stock.api";
export function useStockSearch() {
const [keyword, setKeyword] = useState("삼성전자");
const [searchResults, setSearchResults] = useState<
DashboardStockSearchItem[]
>([]);
const [error, setError] = useState<string | null>(null);
const [isSearching, setIsSearching] = useState(false);
const requestIdRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
const loadSearch = useCallback(async (query: string) => {
const requestId = ++requestIdRef.current;
const controller = new AbortController();
abortRef.current?.abort();
abortRef.current = controller;
setIsSearching(true);
setError(null);
try {
const data = await fetchStockSearch(query, controller.signal);
if (requestId === requestIdRef.current) {
setSearchResults(data.items);
}
return data.items;
} catch (err) {
if (controller.signal.aborted) {
return [];
}
if (requestId === requestIdRef.current) {
setError(
err instanceof Error
? err.message
: "종목 검색 중 오류가 발생했습니다.",
);
}
return [];
} finally {
if (requestId === requestIdRef.current) {
setIsSearching(false);
}
}
}, []);
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],
);
const clearSearch = useCallback(() => {
abortRef.current?.abort();
setSearchResults([]);
setError(null);
setIsSearching(false);
}, []);
return {
keyword,
setKeyword,
searchResults,
setSearchResults,
error,
setError,
isSearching,
search,
clearSearch,
};
}

View File

@@ -3,6 +3,7 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { KisTradingEnv } from "@/features/dashboard/types/dashboard.types";
import { fetchKisWebSocketApproval } from "@/features/dashboard/apis/kis-auth.api";
/**
* @file features/dashboard/store/use-kis-runtime-store.ts
@@ -13,6 +14,7 @@ export interface KisRuntimeCredentials {
appKey: string;
appSecret: string;
tradingEnv: KisTradingEnv;
accountNo: string;
}
interface KisRuntimeStoreState {
@@ -20,11 +22,15 @@ interface KisRuntimeStoreState {
kisTradingEnvInput: KisTradingEnv;
kisAppKeyInput: string;
kisAppSecretInput: string;
kisAccountNoInput: string;
// [State] 검증/연동 상태
verifiedCredentials: KisRuntimeCredentials | null;
isKisVerified: boolean;
tradingEnv: KisTradingEnv;
// [State] 웹소켓 승인키
wsApprovalKey: string | null;
}
interface KisRuntimeStoreActions {
@@ -46,13 +52,21 @@ interface KisRuntimeStoreActions {
* @see features/dashboard/components/dashboard-main.tsx App Secret onChange 이벤트
*/
setKisAppSecretInput: (appSecret: string) => void;
/**
* 계좌번호 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param accountNo 계좌번호 (8자리-2자리)
*/
setKisAccountNoInput: (accountNo: string) => void;
/**
* 검증 성공 상태를 저장합니다.
* @param credentials 검증 완료된 키
* @param tradingEnv 현재 연동 모드
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis
*/
setVerifiedKisSession: (credentials: KisRuntimeCredentials, tradingEnv: KisTradingEnv) => void;
setVerifiedKisSession: (
credentials: KisRuntimeCredentials,
tradingEnv: KisTradingEnv,
) => void;
/**
* 검증 실패 또는 입력 변경 시 검증 상태만 초기화합니다.
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis catch
@@ -64,20 +78,33 @@ interface KisRuntimeStoreActions {
* @see features/dashboard/components/dashboard-main.tsx handleRevokeKis
*/
clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void;
/**
* 웹소켓 승인키를 가져오거나 없으면 발급받습니다.
* @returns approvalKey
*/
getOrFetchApprovalKey: () => Promise<string | null>;
}
const INITIAL_STATE: KisRuntimeStoreState = {
kisTradingEnvInput: "real",
kisAppKeyInput: "",
kisAppSecretInput: "",
kisAccountNoInput: "",
verifiedCredentials: null,
isKisVerified: false,
tradingEnv: "real",
wsApprovalKey: null,
};
export const useKisRuntimeStore = create<KisRuntimeStoreState & KisRuntimeStoreActions>()(
// 동시 요청 방지를 위한 모듈 스코프 변수
let approvalPromise: Promise<string | null> | null = null;
export const useKisRuntimeStore = create<
KisRuntimeStoreState & KisRuntimeStoreActions
>()(
persist(
(set) => ({
(set, get) => ({
...INITIAL_STATE,
setKisTradingEnvInput: (tradingEnv) =>
@@ -85,6 +112,7 @@ export const useKisRuntimeStore = create<KisRuntimeStoreState & KisRuntimeStoreA
kisTradingEnvInput: tradingEnv,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
setKisAppKeyInput: (appKey) =>
@@ -92,6 +120,7 @@ export const useKisRuntimeStore = create<KisRuntimeStoreState & KisRuntimeStoreA
kisAppKeyInput: appKey,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
setKisAppSecretInput: (appSecret) =>
@@ -99,6 +128,15 @@ export const useKisRuntimeStore = create<KisRuntimeStoreState & KisRuntimeStoreA
kisAppSecretInput: appSecret,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
setKisAccountNoInput: (accountNo) =>
set({
kisAccountNoInput: accountNo,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
setVerifiedKisSession: (credentials, tradingEnv) =>
@@ -106,12 +144,15 @@ export const useKisRuntimeStore = create<KisRuntimeStoreState & KisRuntimeStoreA
verifiedCredentials: credentials,
isKisVerified: true,
tradingEnv,
// 인증이 바뀌면 승인키도 초기화
wsApprovalKey: null,
}),
invalidateKisVerification: () =>
set({
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
clearKisRuntimeSession: (tradingEnv) =>
@@ -119,10 +160,50 @@ export const useKisRuntimeStore = create<KisRuntimeStoreState & KisRuntimeStoreA
kisTradingEnvInput: tradingEnv,
kisAppKeyInput: "",
kisAppSecretInput: "",
kisAccountNoInput: "",
verifiedCredentials: null,
isKisVerified: false,
tradingEnv,
wsApprovalKey: null,
}),
getOrFetchApprovalKey: async () => {
const { wsApprovalKey, verifiedCredentials } = get();
// 1. 이미 키가 있으면 반환
if (wsApprovalKey) {
return wsApprovalKey;
}
// 2. 인증 정보가 없으면 실패
if (!verifiedCredentials) {
return null;
}
// 3. 이미 진행 중인 요청이 있다면 해당 Promise 반환 (Deduping)
if (approvalPromise) {
return approvalPromise;
}
// 4. API 호출
approvalPromise = (async () => {
try {
const data = await fetchKisWebSocketApproval(verifiedCredentials);
if (data.approvalKey) {
set({ wsApprovalKey: data.approvalKey });
return data.approvalKey;
}
return null;
} catch (error) {
console.error(error);
return null;
} finally {
approvalPromise = null;
}
})();
return approvalPromise;
},
}),
{
name: "autotrade-kis-runtime-store",
@@ -131,9 +212,17 @@ export const useKisRuntimeStore = create<KisRuntimeStoreState & KisRuntimeStoreA
kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput,
kisAccountNoInput: state.kisAccountNoInput,
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
tradingEnv: state.tradingEnv,
// wsApprovalKey도 로컬 스토리지에 저장하여 새로고침 후에도 유지 (선택사항이나 유지하는 게 유리)
// 단, 승인키 유효기간 문제가 있을 수 있으나 API 실패 시 재발급 로직을 넣거나,
// 현재 로직상 인증 정보가 바뀌면 초기화되므로 저장해도 무방.
// 하지만 유효기간 만료 처리가 없으므로 일단 저장하지 않는 게 안전할 수도 있음.
// 사용자가 "새로고침"을 하는 빈도보다 "일반적인 사용"이 많으므로 저장하지 않음 (partialize에서 제외)
// -> 코드를 보니 여기 포함시키지 않으면 저장이 안 됨.
// 유효기간 처리가 없으니 승인키는 메모리에만 유지하도록 함 (새로고침 시 재발급)
}),
},
),

View File

@@ -1,10 +1,13 @@
/**
/**
* @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 DashboardPriceSource =
| "inquire-price"
| "inquire-ccnl"
| "inquire-overtime-price";
export type DashboardMarketPhase = "regular" | "afterHours";
/**
@@ -23,6 +26,24 @@ export interface KoreanStockIndexItem {
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;
}
/**
@@ -73,6 +94,89 @@ export interface DashboardStockOverviewResponse {
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 응답
*/

View File

@@ -0,0 +1,269 @@
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]);
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: Allow H0STCNT0 (Real/Mock) or H0STOUP0 (Overtime)
const receivedTrId = parts[1];
if (receivedTrId !== "H0STCNT0" && receivedTrId !== "H0STOUP0") {
// console.warn("[KisRealtime] Unknown TR ID for Trade Tick:", 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;
}
export function formatRealtimeTickTime(hhmmss?: string) {
if (!hhmmss || hhmmss.length !== 6) return "실시간";
return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`;
}
export function appendRealtimeTick(
prev: StockCandlePoint[],
next: StockCandlePoint,
) {
if (prev.length === 0) return [next];
const last = prev[prev.length - 1];
if (last.time === next.time) {
return [...prev.slice(0, -1), next];
}
return [...prev, next].slice(-80);
}
export function toTickOrderValue(hhmmss?: string) {
if (!hhmmss || !/^\d{6}$/.test(hhmmss)) return -1;
return Number(hhmmss);
}
/**
* 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;
}

View File

@@ -1,11 +1,6 @@
/**
/**
* @file features/layout/components/header.tsx
* @description 애플리케이션 상단 헤더 컴포넌트 (네비게이션, 테마, 유저 메뉴)
* @remarks
* - [레이어] Components/UI/Layout
* - [사용자 행동] 홈 이동, 테마 변경, 로그인/회원가입 이동, 대시보드 이동
* - [데이터 흐름] User Prop -> UI Conditional Rendering
* - [연관 파일] layout.tsx, session-timer.tsx, user-menu.tsx
* @description 애플리케이션 상단 헤더 컴포넌트
*/
import Link from "next/link";
@@ -14,74 +9,139 @@ import { AUTH_ROUTES } from "@/features/auth/constants";
import { UserMenu } from "@/features/layout/components/user-menu";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { SessionTimer } from "@/features/auth/components/session-timer";
import { cn } from "@/lib/utils";
interface HeaderProps {
/** 현재 로그인 사용자 정보 (없으면 null) */
/** 현재 로그인 사용자 정보(null 가능) */
user: User | null;
/** 대시보드 링크 표시 여부 */
/** 대시보드 링크 버튼 노출 여부 */
showDashboardLink?: boolean;
/** 홈 랜딩에서 배경과 자연스럽게 섞이는 헤더 모드 */
blendWithBackground?: boolean;
}
/**
* 글로벌 헤더 컴포넌트
* @param user Supabase User 객체
* @param showDashboardLink 대시보드 바로가기 버튼 노출 여부
* @param showDashboardLink 대시보드 버튼 노출 여부
* @param blendWithBackground 홈 랜딩 전용 반투명 모드
* @returns Header JSX
* @see layout.tsx - RootLayout에서 데이터 주입하여 호출
* @see app/(home)/page.tsx 홈 랜딩에서 blendWithBackground=true로 호출
*/
export function Header({ user, showDashboardLink = false }: HeaderProps) {
export function Header({
user,
showDashboardLink = false,
blendWithBackground = false,
}: HeaderProps) {
return (
<header className="fixed top-0 z-40 w-full border-b border-border/40 bg-background/80 backdrop-blur-xl supports-backdrop-filter:bg-background/60">
<div className="flex h-16 w-full items-center justify-between px-4 md:px-6">
{/* ========== 좌측: 로고 영역 ========== */}
<Link href={AUTH_ROUTES.HOME} className="flex items-center gap-2 group">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10 transition-transform duration-200 group-hover:scale-110">
<header
className={cn(
"fixed inset-x-0 top-0 z-50 w-full",
blendWithBackground
? "text-white"
: "border-b border-border/40 bg-background/80 backdrop-blur-xl supports-backdrop-filter:bg-background/60",
)}
>
{blendWithBackground && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-linear-to-b from-black/70 via-black/35 to-transparent"
/>
)}
<div
className={cn(
"relative z-10 flex h-16 w-full items-center justify-between px-4 md:px-6",
blendWithBackground
? "bg-black/30 backdrop-blur-xl supports-backdrop-filter:bg-black/20"
: "",
)}
>
{/* ========== LEFT: LOGO SECTION ========== */}
<Link
href={AUTH_ROUTES.HOME}
className={cn("group flex items-center gap-2", blendWithBackground ? "text-white" : "")}
>
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-110",
blendWithBackground ? "bg-brand-500/45 ring-1 ring-white/55" : "bg-primary/10",
)}
>
<div className="h-5 w-5 rounded-lg bg-linear-to-br from-brand-500 to-brand-700" />
</div>
<span className="text-xl font-bold tracking-tight text-foreground transition-colors group-hover:text-primary">
<span
className={cn(
"text-xl font-bold tracking-tight transition-colors",
blendWithBackground
? "!text-white [text-shadow:0_2px_18px_rgba(0,0,0,0.75)] group-hover:text-brand-100"
: "text-foreground group-hover:text-primary",
)}
>
AutoTrade
</span>
</Link>
{/* ========== 우측: 액션 버튼 영역 ========== */}
<div className="flex items-center gap-4">
{/* 테마 토글 */}
<ThemeToggle />
{/* ========== RIGHT: ACTION SECTION ========== */}
<div className={cn("flex items-center gap-2 sm:gap-3", blendWithBackground ? "text-white" : "")}
>
<ThemeToggle
className={cn(
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md hover:bg-black/65 focus-visible:ring-white/80"
: "",
)}
iconClassName={blendWithBackground ? "!text-white" : undefined}
/>
{user ? (
// [Case 1] 로그인 상태
<>
{/* 세션 타임아웃 타이머 */}
<SessionTimer />
<SessionTimer blendWithBackground={blendWithBackground} />
{showDashboardLink && (
<Button
asChild
variant="ghost"
size="sm"
className="hidden sm:inline-flex"
className={cn(
"hidden font-medium sm:inline-flex",
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md [text-shadow:0_1px_8px_rgba(0,0,0,0.45)] hover:bg-black/65 hover:!text-white"
: "",
)}
>
<Link href={AUTH_ROUTES.DASHBOARD}></Link>
</Button>
)}
{/* 사용자 드롭다운 메뉴 */}
<UserMenu user={user} />
<UserMenu user={user} blendWithBackground={blendWithBackground} />
</>
) : (
// [Case 2] 비로그인 상태
<div className="flex items-center gap-2">
<Button
asChild
variant="ghost"
size="sm"
className="hidden sm:inline-flex"
className={cn(
"hidden sm:inline-flex",
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md hover:bg-black/65 hover:!text-white"
: "",
)}
>
<Link href={AUTH_ROUTES.LOGIN}></Link>
</Button>
<Button asChild size="sm" className="rounded-full px-6">
<Button
asChild
size="sm"
className={cn(
"rounded-full px-6",
blendWithBackground
? "bg-brand-500/90 text-white shadow-lg shadow-brand-700/40 hover:bg-brand-400"
: "",
)}
>
<Link href={AUTH_ROUTES.SIGNUP}></Link>
</Button>
</div>

View File

@@ -1,14 +1,13 @@
/**
/**
* @file features/layout/components/user-menu.tsx
* @description 사용자 프로필 드롭다운 메뉴 컴포넌트
* @remarks
* - [레이어] Components/UI
* - [사용자 행동] Avatar 클릭 -> 드롭다운 오픈 -> 프로필/설정 이동 또는 로그아웃
* - [연관 파일] header.tsx, features/auth/actions.ts (로그아웃)
*/
"use client";
import { User } from "@supabase/supabase-js";
import { LogOut, Settings, User as UserIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { signout } from "@/features/auth/actions";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
@@ -19,21 +18,23 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { User } from "@supabase/supabase-js";
import { LogOut, Settings, User as UserIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
interface UserMenuProps {
/** Supabase User 객체 */
user: User | null;
/** 홈 랜딩의 shader 배경 위에서 대비를 높이는 모드 */
blendWithBackground?: boolean;
}
/**
* 사용자 메뉴/프로필 컴포넌트 (로그인 시 헤더 노출)
* 사용자 메뉴/프로필 컴포넌트
* @param user 로그인한 사용자 정보
* @returns Avatar 버튼 및 드롭다운 메뉴
* @param blendWithBackground shader 배경 위 가독성 모드
* @returns Avatar 버튼 + 드롭다운 메뉴
* @see features/layout/components/header.tsx 헤더 우측 액션 영역에서 호출
*/
export function UserMenu({ user }: UserMenuProps) {
export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
const router = useRouter();
if (!user) return null;
@@ -41,38 +42,55 @@ export function UserMenu({ user }: UserMenuProps) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 outline-none">
<Avatar className="h-8 w-8 transition-opacity hover:opacity-80">
<button
className={cn(
"flex items-center gap-2 rounded-full outline-none transition-colors",
blendWithBackground
? "ring-1 ring-white/30 hover:bg-black/30 focus-visible:ring-2 focus-visible:ring-white/70"
: "",
)}
aria-label="사용자 메뉴 열기"
>
<Avatar className="h-8 w-8 transition-opacity hover:opacity-90">
<AvatarImage src={user.user_metadata?.avatar_url} />
<AvatarFallback className="bg-linear-to-br from-brand-500 to-brand-700 text-white text-xs font-bold">
<AvatarFallback
className={cn(
"text-xs font-bold text-white",
blendWithBackground
? "bg-brand-500/90 [text-shadow:0_1px_8px_rgba(0,0,0,0.45)]"
: "bg-linear-to-br from-brand-500 to-brand-700",
)}
>
{user.email?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user.user_metadata?.full_name ||
user.user_metadata?.name ||
"사용자"}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
{user.user_metadata?.full_name || user.user_metadata?.name || "사용자"}
</p>
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push("/profile")}>
<UserIcon className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/settings")}>
<Settings className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<form action={signout}>
<DropdownMenuItem asChild>
<button className="w-full text-red-600 dark:text-red-400">