diff --git a/.env.example b/.env.example index b937669..809a30d 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,9 @@ KIS_APP_KEY_MOCK= KIS_APP_SECRET_MOCK= KIS_BASE_URL_MOCK=https://openapivts.koreainvestment.com:29443 KIS_WS_URL_MOCK=ws://ops.koreainvestment.com:31000 + +# (선택) 서버에서 기본 계좌번호를 사용할 경우 +# 형식: KIS_ACCOUNT_NO=12345678 또는 12345678-01 +# KIS_ACCOUNT_PRODUCT_CODE=01 +KIS_ACCOUNT_NO= +KIS_ACCOUNT_PRODUCT_CODE= diff --git a/.tmp/open-trading-api b/.tmp/open-trading-api deleted file mode 160000 index aea5e77..0000000 --- a/.tmp/open-trading-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit aea5e779da24bec2ec0c23d3d9fe400d8d5db234 diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index d315e98..16d20a0 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -4,13 +4,13 @@ */ import { redirect } from "next/navigation"; +import { DashboardContainer } from "@/features/dashboard/components/DashboardContainer"; import { createClient } from "@/utils/supabase/server"; /** - * 대시보드 페이지 (향후 확장용) - * @returns 빈 대시보드 안내 UI - * @see app/(main)/trade/page.tsx 트레이딩 기능은 `/trade` 경로에서 제공합니다. - * @see app/(main)/settings/page.tsx KIS 인증 설정은 `/settings` 경로에서 제공합니다. + * 대시보드 페이지 + * @returns DashboardContainer UI + * @see features/dashboard/components/DashboardContainer.tsx 대시보드 상태 헤더/지수/보유종목 UI를 제공합니다. */ export default async function DashboardPage() { // 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다. @@ -21,17 +21,5 @@ export default async function DashboardPage() { if (!user) redirect("/login"); - return ( -
- {/* ========== DASHBOARD PLACEHOLDER ========== */} -
-

- 대시보드 -

-

- 이 페이지는 향후 포트폴리오 요약과 리포트 기능을 위한 확장 영역입니다. -

-
-
- ); + return ; } diff --git a/app/api/kis/domestic/_shared.ts b/app/api/kis/domestic/_shared.ts new file mode 100644 index 0000000..595106d --- /dev/null +++ b/app/api/kis/domestic/_shared.ts @@ -0,0 +1,45 @@ +import { parseKisAccountParts } from "@/lib/kis/account"; +import { + normalizeTradingEnv, + type KisCredentialInput, +} from "@/lib/kis/config"; + +/** + * @description 요청 헤더에서 KIS 키를 읽어옵니다. + * @param headers 요청 헤더 + * @returns KIS 인증 입력값 + * @see app/api/kis/domestic/balance/route.ts 대시보드 잔고 API 인증키 파싱 + * @see app/api/kis/domestic/indices/route.ts 대시보드 지수 API 인증키 파싱 + */ +export function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput { + const appKey = headers.get("x-kis-app-key")?.trim(); + const appSecret = headers.get("x-kis-app-secret")?.trim(); + const tradingEnv = normalizeTradingEnv( + headers.get("x-kis-trading-env") ?? undefined, + ); + + return { + appKey, + appSecret, + tradingEnv, + }; +} + +/** + * @description 요청 헤더(또는 서버 환경변수)에서 계좌번호(8-2)를 읽어옵니다. + * @param headers 요청 헤더 + * @returns 계좌번호 파트(8 + 2) 또는 null + * @see app/api/kis/domestic/balance/route.ts 잔고 조회 시 필수 계좌정보 파싱 + */ +export function readKisAccountParts(headers: Headers) { + const headerAccountNo = headers.get("x-kis-account-no"); + const headerAccountProductCode = headers.get("x-kis-account-product-code"); + + const envAccountNo = process.env.KIS_ACCOUNT_NO; + const envAccountProductCode = process.env.KIS_ACCOUNT_PRODUCT_CODE; + + return ( + parseKisAccountParts(headerAccountNo, headerAccountProductCode) ?? + parseKisAccountParts(envAccountNo, envAccountProductCode) + ); +} diff --git a/app/api/kis/domestic/balance/route.ts b/app/api/kis/domestic/balance/route.ts new file mode 100644 index 0000000..a63dbea --- /dev/null +++ b/app/api/kis/domestic/balance/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashboard.types"; +import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; +import { getDomesticDashboardBalance } from "@/lib/kis/dashboard"; +import { + readKisAccountParts, + readKisCredentialsFromHeaders, +} from "@/app/api/kis/domestic/_shared"; + +/** + * @file app/api/kis/domestic/balance/route.ts + * @description 국내주식 계좌 잔고/보유종목 조회 API + */ + +/** + * 대시보드 잔고 조회 API + * @returns 총자산/손익/보유종목 목록 + * @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다. + */ +export async function GET(request: Request) { + const credentials = readKisCredentialsFromHeaders(request.headers); + + if (!hasKisConfig(credentials)) { + return NextResponse.json( + { + error: "KIS API 키 설정이 필요합니다.", + }, + { status: 400 }, + ); + } + + const account = readKisAccountParts(request.headers); + if (!account) { + return NextResponse.json( + { + error: + "계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.", + }, + { status: 400 }, + ); + } + + try { + const result = await getDomesticDashboardBalance(account, credentials); + const response: DashboardBalanceResponse = { + source: "kis", + tradingEnv: normalizeTradingEnv(credentials.tradingEnv), + summary: result.summary, + holdings: result.holdings, + fetchedAt: new Date().toISOString(), + }; + + return NextResponse.json(response, { + headers: { + "cache-control": "no-store", + }, + }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "잔고 조회 중 오류가 발생했습니다."; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/kis/domestic/indices/route.ts b/app/api/kis/domestic/indices/route.ts new file mode 100644 index 0000000..c7ebce8 --- /dev/null +++ b/app/api/kis/domestic/indices/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashboard.types"; +import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; +import { getDomesticDashboardIndices } from "@/lib/kis/dashboard"; +import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared"; + +/** + * @file app/api/kis/domestic/indices/route.ts + * @description 국내 주요 지수(KOSPI/KOSDAQ) 조회 API + */ + +/** + * 대시보드 지수 조회 API + * @returns 코스피/코스닥 지수 목록 + * @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/주기 갱신에서 호출합니다. + */ +export async function GET(request: Request) { + const credentials = readKisCredentialsFromHeaders(request.headers); + + if (!hasKisConfig(credentials)) { + return NextResponse.json( + { + error: "KIS API 키 설정이 필요합니다.", + }, + { status: 400 }, + ); + } + + try { + const items = await getDomesticDashboardIndices(credentials); + const response: DashboardIndicesResponse = { + source: "kis", + tradingEnv: normalizeTradingEnv(credentials.tradingEnv), + items, + fetchedAt: new Date().toISOString(), + }; + + return NextResponse.json(response, { + headers: { + "cache-control": "no-store", + }, + }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "지수 조회 중 오류가 발생했습니다."; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/features/auth/components/session-manager.tsx b/features/auth/components/session-manager.tsx index ce12027..a980465 100644 --- a/features/auth/components/session-manager.tsx +++ b/features/auth/components/session-manager.tsx @@ -29,6 +29,12 @@ import { SESSION_TIMEOUT_MS } from "@/features/auth/constants"; // 설정: 경고 표시 시간 (타임아웃 1분 전) - 현재 미사용 (즉시 로그아웃) // const WARNING_MS = 60 * 1000; +const SESSION_RELATED_STORAGE_KEYS = [ + "session-storage", + "auth-storage", + "autotrade-kis-runtime-store", +] as const; + /** * 세션 관리자 컴포넌트 * 사용자 활동을 감지하여 세션 연장 및 타임아웃 처리 @@ -51,6 +57,18 @@ export function SessionManager() { const { setLastActive } = useSessionStore(); + /** + * @description 세션 만료 로그아웃 시 세션 관련 로컬 스토리지를 정리합니다. + * @see features/layout/components/user-menu.tsx 수동 로그아웃 경로에서도 동일한 키를 제거합니다. + */ + const clearSessionRelatedStorage = useCallback(() => { + if (typeof window === "undefined") return; + + for (const key of SESSION_RELATED_STORAGE_KEYS) { + window.localStorage.removeItem(key); + } + }, []); + /** * 로그아웃 처리 핸들러 * @see session-timer.tsx - 타임아웃 시 동일한 로직 필요 가능성 있음 @@ -64,11 +82,12 @@ export function SessionManager() { // [Step 3] 로컬 스토어 및 세션 정보 초기화 useSessionStore.persist.clearStorage(); + clearSessionRelatedStorage(); // [Step 4] 로그인 페이지로 리다이렉트 및 메시지 표시 router.push("/login?message=세션이 만료되었습니다. 다시 로그인해주세요."); router.refresh(); - }, [router]); + }, [clearSessionRelatedStorage, router]); useEffect(() => { if (isAuthPage) return; @@ -79,6 +98,10 @@ export function SessionManager() { if (showWarning) setShowWarning(false); }; + // [Step 0] 인증 페이지에서 메인 페이지로 진입한 직후를 "활동"으로 간주해 + // 이전 세션 잔여 시간(예: 00:00)으로 즉시 로그아웃되는 현상을 방지합니다. + updateLastActive(); + // [Step 1] 사용자 활동 이벤트 감지 (마우스, 키보드, 스크롤, 터치) const events = ["mousedown", "keydown", "scroll", "touchstart"]; const handleActivity = () => updateLastActive(); diff --git a/features/dashboard/apis/dashboard.api.ts b/features/dashboard/apis/dashboard.api.ts new file mode 100644 index 0000000..11a2d95 --- /dev/null +++ b/features/dashboard/apis/dashboard.api.ts @@ -0,0 +1,86 @@ +import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; +import type { + DashboardBalanceResponse, + DashboardIndicesResponse, +} from "@/features/dashboard/types/dashboard.types"; + +/** + * @file features/dashboard/apis/dashboard.api.ts + * @description 대시보드 잔고/지수 API 클라이언트 + */ + +/** + * 계좌 잔고/보유종목을 조회합니다. + * @param credentials KIS 인증 정보 + * @returns 잔고 응답 + * @see app/api/kis/domestic/balance/route.ts 서버 라우트 + */ +export async function fetchDashboardBalance( + credentials: KisRuntimeCredentials, +): Promise { + const response = await fetch("/api/kis/domestic/balance", { + method: "GET", + headers: buildKisRequestHeaders(credentials), + cache: "no-store", + }); + + const payload = (await response.json()) as + | DashboardBalanceResponse + | { error?: string }; + + if (!response.ok) { + throw new Error( + "error" in payload ? payload.error : "잔고 조회 중 오류가 발생했습니다.", + ); + } + + return payload as DashboardBalanceResponse; +} + +/** + * 시장 지수(KOSPI/KOSDAQ)를 조회합니다. + * @param credentials KIS 인증 정보 + * @returns 지수 응답 + * @see app/api/kis/domestic/indices/route.ts 서버 라우트 + */ +export async function fetchDashboardIndices( + credentials: KisRuntimeCredentials, +): Promise { + const response = await fetch("/api/kis/domestic/indices", { + method: "GET", + headers: buildKisRequestHeaders(credentials), + cache: "no-store", + }); + + const payload = (await response.json()) as + | DashboardIndicesResponse + | { error?: string }; + + if (!response.ok) { + throw new Error( + "error" in payload ? payload.error : "지수 조회 중 오류가 발생했습니다.", + ); + } + + return payload as DashboardIndicesResponse; +} + +/** + * 대시보드 API 공통 헤더를 구성합니다. + * @param credentials KIS 인증 정보 + * @returns KIS 전달 헤더 + * @see features/dashboard/apis/dashboard.api.ts fetchDashboardBalance/fetchDashboardIndices + */ +function buildKisRequestHeaders(credentials: KisRuntimeCredentials) { + const headers: Record = { + "x-kis-app-key": credentials.appKey, + "x-kis-app-secret": credentials.appSecret, + "x-kis-trading-env": credentials.tradingEnv, + }; + + if (credentials.accountNo?.trim()) { + headers["x-kis-account-no"] = credentials.accountNo.trim(); + } + + return headers; +} diff --git a/features/dashboard/components/DashboardAccessGate.tsx b/features/dashboard/components/DashboardAccessGate.tsx new file mode 100644 index 0000000..1e90234 --- /dev/null +++ b/features/dashboard/components/DashboardAccessGate.tsx @@ -0,0 +1,37 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +interface DashboardAccessGateProps { + canAccess: boolean; +} + +/** + * @description KIS 인증 여부에 따라 대시보드 접근 가이드를 렌더링합니다. + * @param canAccess 대시보드 접근 가능 여부 + * @see features/dashboard/components/DashboardContainer.tsx 인증되지 않은 경우 이 컴포넌트를 렌더링합니다. + */ +export function DashboardAccessGate({ canAccess }: DashboardAccessGateProps) { + if (canAccess) return null; + + return ( +
+
+ {/* ========== UNVERIFIED NOTICE ========== */} +

+ 대시보드를 보려면 KIS API 인증이 필요합니다. +

+

+ 설정 페이지에서 App Key/App Secret(그리고 계좌번호)을 입력하고 연결을 + 완료해 주세요. +

+ + {/* ========== ACTION ========== */} +
+ +
+
+
+ ); +} diff --git a/features/dashboard/components/DashboardContainer.tsx b/features/dashboard/components/DashboardContainer.tsx new file mode 100644 index 0000000..25aef94 --- /dev/null +++ b/features/dashboard/components/DashboardContainer.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { DashboardAccessGate } from "@/features/dashboard/components/DashboardAccessGate"; +import { DashboardSkeleton } from "@/features/dashboard/components/DashboardSkeleton"; +import { HoldingsList } from "@/features/dashboard/components/HoldingsList"; +import { MarketSummary } from "@/features/dashboard/components/MarketSummary"; +import { StatusHeader } from "@/features/dashboard/components/StatusHeader"; +import { StockDetailPreview } from "@/features/dashboard/components/StockDetailPreview"; +import { useDashboardData } from "@/features/dashboard/hooks/use-dashboard-data"; +import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; + +/** + * @description 대시보드 메인 컨테이너입니다. + * @remarks UI 흐름: 대시보드 진입 -> useDashboardData API 호출 -> StatusHeader/MarketSummary/HoldingsList/StockDetailPreview 순으로 렌더링 + * @see app/(main)/dashboard/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다. + * @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 데이터 조회/갱신 상태를 관리합니다. + */ +export function DashboardContainer() { + const { + verifiedCredentials, + isKisVerified, + _hasHydrated, + wsApprovalKey, + wsUrl, + } = useKisRuntimeStore( + useShallow((state) => ({ + verifiedCredentials: state.verifiedCredentials, + isKisVerified: state.isKisVerified, + _hasHydrated: state._hasHydrated, + wsApprovalKey: state.wsApprovalKey, + wsUrl: state.wsUrl, + })), + ); + + const canAccess = isKisVerified && Boolean(verifiedCredentials); + + const { + balance, + indices, + selectedHolding, + selectedSymbol, + setSelectedSymbol, + isLoading, + isRefreshing, + balanceError, + indicesError, + lastUpdatedAt, + refresh, + } = useDashboardData(canAccess ? verifiedCredentials : null); + + const isKisRestConnected = useMemo(() => { + if (indices.length > 0) return true; + if (balance && !balanceError) return true; + return false; + }, [balance, balanceError, indices.length]); + + if (!_hasHydrated) { + return ( +
+ +
+ ); + } + + if (!canAccess) { + return ; + } + + if (isLoading && !balance && indices.length === 0) { + return ; + } + + return ( +
+ {/* ========== STATUS HEADER ========== */} + { + void refresh(); + }} + /> + + {/* ========== MAIN CONTENT GRID ========== */} +
+ + +
+ + + +
+
+
+ ); +} diff --git a/features/dashboard/components/DashboardSkeleton.tsx b/features/dashboard/components/DashboardSkeleton.tsx new file mode 100644 index 0000000..47cbb77 --- /dev/null +++ b/features/dashboard/components/DashboardSkeleton.tsx @@ -0,0 +1,59 @@ +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * @description 대시보드 초기 로딩 스켈레톤 UI입니다. + * @see features/dashboard/components/DashboardContainer.tsx isLoading 상태에서 렌더링합니다. + */ +export function DashboardSkeleton() { + return ( +
+ {/* ========== HEADER SKELETON ========== */} + + + + + + + + + + {/* ========== BODY SKELETON ========== */} +
+ + + + + + {Array.from({ length: 6 }).map((_, index) => ( + + ))} + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/features/dashboard/components/HoldingsList.tsx b/features/dashboard/components/HoldingsList.tsx new file mode 100644 index 0000000..ae26aa8 --- /dev/null +++ b/features/dashboard/components/HoldingsList.tsx @@ -0,0 +1,123 @@ +import { AlertCircle, Wallet2 } from "lucide-react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types"; +import { + formatCurrency, + formatPercent, + getChangeToneClass, +} from "@/features/dashboard/utils/dashboard-format"; +import { cn } from "@/lib/utils"; + +interface HoldingsListProps { + holdings: DashboardHoldingItem[]; + selectedSymbol: string | null; + isLoading: boolean; + error: string | null; + onSelect: (symbol: string) => void; +} + +/** + * @description 보유 종목 리스트 카드입니다. + * @see features/dashboard/components/DashboardContainer.tsx 좌측 메인 영역에서 호출합니다. + */ +export function HoldingsList({ + holdings, + selectedSymbol, + isLoading, + error, + onSelect, +}: HoldingsListProps) { + return ( + + + {/* ========== TITLE ========== */} + + + 보유 종목 + + + 현재 보유 중인 종목을 선택하면 우측 상세가 갱신됩니다. + + + + + {isLoading && holdings.length === 0 && ( +

보유 종목을 불러오는 중입니다.

+ )} + + {error && ( +

+ + {error} +

+ )} + + {!isLoading && holdings.length === 0 && !error && ( +

보유 종목이 없습니다.

+ )} + + {holdings.length > 0 && ( + +
+ {holdings.map((holding) => { + const isSelected = selectedSymbol === holding.symbol; + const toneClass = getChangeToneClass(holding.profitLoss); + + return ( + + ); + })} +
+
+ )} +
+
+ ); +} diff --git a/features/dashboard/components/MarketSummary.tsx b/features/dashboard/components/MarketSummary.tsx new file mode 100644 index 0000000..3679b21 --- /dev/null +++ b/features/dashboard/components/MarketSummary.tsx @@ -0,0 +1,85 @@ +import { AlertCircle, BarChart3 } from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { DashboardMarketIndexItem } from "@/features/dashboard/types/dashboard.types"; +import { + formatCurrency, + formatSignedCurrency, + formatSignedPercent, + getChangeToneClass, +} from "@/features/dashboard/utils/dashboard-format"; +import { cn } from "@/lib/utils"; + +interface MarketSummaryProps { + items: DashboardMarketIndexItem[]; + isLoading: boolean; + error: string | null; +} + +/** + * @description 코스피/코스닥 지수 요약 카드입니다. + * @see features/dashboard/components/DashboardContainer.tsx 우측 상단 영역에서 호출합니다. + */ +export function MarketSummary({ items, isLoading, error }: MarketSummaryProps) { + return ( + + + {/* ========== TITLE ========== */} + + + 시장 요약 + + + KOSPI/KOSDAQ 주요 지수 변동을 보여줍니다. + + + + + {isLoading && items.length === 0 && ( +

지수 데이터를 불러오는 중입니다.

+ )} + + {error && ( +

+ + {error} +

+ )} + + {items.map((item) => { + const toneClass = getChangeToneClass(item.change); + + return ( +
+
+
+

{item.market}

+

{item.name}

+
+

+ {formatCurrency(item.price)} +

+
+
+ {formatSignedCurrency(item.change)} + {formatSignedPercent(item.changeRate)} +
+
+ ); + })} + + {!isLoading && items.length === 0 && !error && ( +

표시할 지수 데이터가 없습니다.

+ )} +
+
+ ); +} diff --git a/features/dashboard/components/StatusHeader.tsx b/features/dashboard/components/StatusHeader.tsx new file mode 100644 index 0000000..01449e1 --- /dev/null +++ b/features/dashboard/components/StatusHeader.tsx @@ -0,0 +1,128 @@ +import Link from "next/link"; +import { Activity, RefreshCcw, Settings2, Wifi } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import type { DashboardBalanceSummary } from "@/features/dashboard/types/dashboard.types"; +import { + formatCurrency, + formatPercent, + getChangeToneClass, +} from "@/features/dashboard/utils/dashboard-format"; +import { cn } from "@/lib/utils"; + +interface StatusHeaderProps { + summary: DashboardBalanceSummary | null; + isKisRestConnected: boolean; + isWebSocketReady: boolean; + isRefreshing: boolean; + lastUpdatedAt: string | null; + onRefresh: () => void; +} + +/** + * @description 대시보드 상단 상태 헤더(총자산/손익/연결상태/빠른액션) 컴포넌트입니다. + * @see features/dashboard/components/DashboardContainer.tsx 대시보드 루트에서 상태 값을 전달받아 렌더링합니다. + */ +export function StatusHeader({ + summary, + isKisRestConnected, + isWebSocketReady, + isRefreshing, + lastUpdatedAt, + onRefresh, +}: StatusHeaderProps) { + const toneClass = getChangeToneClass(summary?.totalProfitLoss ?? 0); + const updatedLabel = lastUpdatedAt + ? new Date(lastUpdatedAt).toLocaleTimeString("ko-KR", { + hour12: false, + }) + : "--:--:--"; + + return ( + + {/* ========== BACKGROUND DECORATION ========== */} +
+ + + {/* ========== TOTAL ASSET ========== */} +
+

총 자산

+

+ {summary ? `${formatCurrency(summary.totalAmount)}원` : "-"} +

+

+ 예수금 {summary ? `${formatCurrency(summary.cashBalance)}원` : "-"} +

+
+ + {/* ========== PROFIT/LOSS ========== */} +
+

실시간 손익

+

+ {summary ? `${formatCurrency(summary.totalProfitLoss)}원` : "-"} +

+

+ {summary ? formatPercent(summary.totalProfitRate) : "-"} +

+
+ + {/* ========== CONNECTION STATUS ========== */} +
+

시스템 상태

+
+ + + REST {isKisRestConnected ? "연결됨" : "연결 끊김"} + + + + WS {isWebSocketReady ? "준비됨" : "미연결"} + +
+

+ 마지막 갱신 {updatedLabel} +

+
+ + {/* ========== QUICK ACTIONS ========== */} +
+ + +
+
+ + ); +} diff --git a/features/dashboard/components/StockDetailPreview.tsx b/features/dashboard/components/StockDetailPreview.tsx new file mode 100644 index 0000000..1eea0a2 --- /dev/null +++ b/features/dashboard/components/StockDetailPreview.tsx @@ -0,0 +1,141 @@ +import { BarChartBig, MousePointerClick } from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types"; +import { + formatCurrency, + formatPercent, + getChangeToneClass, +} from "@/features/dashboard/utils/dashboard-format"; +import { cn } from "@/lib/utils"; + +interface StockDetailPreviewProps { + holding: DashboardHoldingItem | null; + totalAmount: number; +} + +/** + * @description 선택 종목 상세 요약 카드입니다. + * @see features/dashboard/components/DashboardContainer.tsx HoldingsList 선택 결과를 전달받아 렌더링합니다. + */ +export function StockDetailPreview({ + holding, + totalAmount, +}: StockDetailPreviewProps) { + if (!holding) { + return ( + + + + + 종목 상세 미리보기 + + + 보유 종목을 선택하면 상세 요약이 표시됩니다. + + + +

+ 왼쪽 보유 종목 리스트에서 종목을 선택해 주세요. +

+
+
+ ); + } + + const profitToneClass = getChangeToneClass(holding.profitLoss); + const allocationRate = + totalAmount > 0 ? Math.min((holding.evaluationAmount / totalAmount) * 100, 100) : 0; + + return ( + + + {/* ========== TITLE ========== */} + + + 종목 상세 미리보기 + + + {holding.name} ({holding.symbol}) · {holding.market} + + + + + {/* ========== PRIMARY METRICS ========== */} +
+ + + + + + +
+ + {/* ========== ALLOCATION BAR ========== */} +
+
+ 총 자산 대비 비중 + {formatPercent(allocationRate)} +
+
+
+
+
+ + {/* ========== QUICK ORDER PLACEHOLDER ========== */} +
+

+ + 간편 주문(준비 중) +

+

+ 향후 이 영역에서 선택 종목의 빠른 매수/매도 기능을 제공합니다. +

+
+ + + ); +} + +interface MetricProps { + label: string; + value: string; + valueClassName?: string; +} + +/** + * @description 상세 카드에서 공통으로 사용하는 지표 행입니다. + * @param label 지표명 + * @param value 지표값 + * @param valueClassName 값 텍스트 색상 클래스 + * @see features/dashboard/components/StockDetailPreview.tsx 종목 상세 지표 표시 + */ +function Metric({ label, value, valueClassName }: MetricProps) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} diff --git a/features/dashboard/hooks/use-dashboard-data.ts b/features/dashboard/hooks/use-dashboard-data.ts new file mode 100644 index 0000000..eb89570 --- /dev/null +++ b/features/dashboard/hooks/use-dashboard-data.ts @@ -0,0 +1,182 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; +import { + fetchDashboardBalance, + fetchDashboardIndices, +} from "@/features/dashboard/apis/dashboard.api"; +import type { + DashboardBalanceResponse, + DashboardHoldingItem, + DashboardIndicesResponse, +} from "@/features/dashboard/types/dashboard.types"; + +interface UseDashboardDataResult { + balance: DashboardBalanceResponse | null; + indices: DashboardIndicesResponse["items"]; + selectedHolding: DashboardHoldingItem | null; + selectedSymbol: string | null; + setSelectedSymbol: (symbol: string) => void; + isLoading: boolean; + isRefreshing: boolean; + balanceError: string | null; + indicesError: string | null; + lastUpdatedAt: string | null; + refresh: () => Promise; +} + +const POLLING_INTERVAL_MS = 60_000; + +/** + * @description 대시보드 잔고/지수 상태를 관리하는 훅입니다. + * @param credentials KIS 인증 정보 + * @returns 대시보드 데이터/로딩/오류 상태 + * @remarks UI 흐름: 대시보드 진입 -> refresh("initial") -> balance/indices API 병렬 호출 -> 카드별 상태 반영 + * @see features/dashboard/components/DashboardContainer.tsx 대시보드 루트 컨테이너 + * @see features/dashboard/apis/dashboard.api.ts 실제 API 호출 함수 + */ +export function useDashboardData( + credentials: KisRuntimeCredentials | null, +): UseDashboardDataResult { + const [balance, setBalance] = useState(null); + const [indices, setIndices] = useState([]); + const [selectedSymbol, setSelectedSymbolState] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [balanceError, setBalanceError] = useState(null); + const [indicesError, setIndicesError] = useState(null); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + + const requestSeqRef = useRef(0); + + const hasAccountNo = Boolean(credentials?.accountNo?.trim()); + + /** + * @description 잔고/지수 데이터를 병렬로 갱신합니다. + * @param mode 초기 로드/수동 새로고침/주기 갱신 구분 + * @see features/dashboard/hooks/use-dashboard-data.ts useEffect 초기 호출/폴링/수동 새로고침 + */ + const refreshInternal = useCallback( + async (mode: "initial" | "manual" | "polling") => { + if (!credentials) return; + + const requestSeq = ++requestSeqRef.current; + const isInitial = mode === "initial"; + + if (isInitial) { + setIsLoading(true); + } else { + setIsRefreshing(true); + } + + const tasks: [ + Promise, + Promise, + ] = [ + hasAccountNo + ? fetchDashboardBalance(credentials) + : Promise.resolve(null), + fetchDashboardIndices(credentials), + ]; + + const [balanceResult, indicesResult] = await Promise.allSettled(tasks); + if (requestSeq !== requestSeqRef.current) return; + + let hasAnySuccess = false; + + if (!hasAccountNo) { + setBalance(null); + setBalanceError( + "계좌번호가 없어 잔고를 조회할 수 없습니다. 설정에서 계좌번호(8-2)를 입력해 주세요.", + ); + setSelectedSymbolState(null); + } else if (balanceResult.status === "fulfilled") { + hasAnySuccess = true; + setBalance(balanceResult.value); + setBalanceError(null); + + setSelectedSymbolState((prev) => { + const nextHoldings = balanceResult.value?.holdings ?? []; + if (nextHoldings.length === 0) return null; + if (prev && nextHoldings.some((item) => item.symbol === prev)) { + return prev; + } + return nextHoldings[0]?.symbol ?? null; + }); + } else { + setBalanceError(balanceResult.reason instanceof Error ? balanceResult.reason.message : "잔고 조회에 실패했습니다."); + } + + if (indicesResult.status === "fulfilled") { + hasAnySuccess = true; + setIndices(indicesResult.value.items); + setIndicesError(null); + } else { + setIndicesError(indicesResult.reason instanceof Error ? indicesResult.reason.message : "시장 지수 조회에 실패했습니다."); + } + + if (hasAnySuccess) { + setLastUpdatedAt(new Date().toISOString()); + } + + if (isInitial) { + setIsLoading(false); + } else { + setIsRefreshing(false); + } + }, + [credentials, hasAccountNo], + ); + + /** + * @description 대시보드 수동 새로고침 핸들러입니다. + * @see features/dashboard/components/StatusHeader.tsx 새로고침 버튼 onClick + */ + const refresh = useCallback(async () => { + await refreshInternal("manual"); + }, [refreshInternal]); + + useEffect(() => { + if (!credentials) return; + + const timeout = window.setTimeout(() => { + void refreshInternal("initial"); + }, 0); + + return () => window.clearTimeout(timeout); + }, [credentials, refreshInternal]); + + useEffect(() => { + if (!credentials) return; + + const interval = window.setInterval(() => { + void refreshInternal("polling"); + }, POLLING_INTERVAL_MS); + + return () => window.clearInterval(interval); + }, [credentials, refreshInternal]); + + const selectedHolding = useMemo(() => { + if (!selectedSymbol || !balance) return null; + return balance.holdings.find((item) => item.symbol === selectedSymbol) ?? null; + }, [balance, selectedSymbol]); + + const setSelectedSymbol = useCallback((symbol: string) => { + setSelectedSymbolState(symbol); + }, []); + + return { + balance, + indices, + selectedHolding, + selectedSymbol, + setSelectedSymbol, + isLoading, + isRefreshing, + balanceError, + indicesError, + lastUpdatedAt, + refresh, + }; +} diff --git a/features/dashboard/types/dashboard.types.ts b/features/dashboard/types/dashboard.types.ts new file mode 100644 index 0000000..a1bc1a5 --- /dev/null +++ b/features/dashboard/types/dashboard.types.ts @@ -0,0 +1,66 @@ +/** + * @file features/dashboard/types/dashboard.types.ts + * @description 대시보드(잔고/지수/보유종목) 전용 타입 정의 + */ + +import type { KisTradingEnv } from "@/features/trade/types/trade.types"; + +export type DashboardMarket = "KOSPI" | "KOSDAQ"; + +/** + * 대시보드 잔고 요약 + */ +export interface DashboardBalanceSummary { + totalAmount: number; + cashBalance: number; + totalProfitLoss: number; + totalProfitRate: number; +} + +/** + * 대시보드 보유 종목 항목 + */ +export interface DashboardHoldingItem { + symbol: string; + name: string; + market: DashboardMarket; + quantity: number; + averagePrice: number; + currentPrice: number; + evaluationAmount: number; + profitLoss: number; + profitRate: number; +} + +/** + * 계좌 잔고 API 응답 모델 + */ +export interface DashboardBalanceResponse { + source: "kis"; + tradingEnv: KisTradingEnv; + summary: DashboardBalanceSummary; + holdings: DashboardHoldingItem[]; + fetchedAt: string; +} + +/** + * 시장 지수 항목 + */ +export interface DashboardMarketIndexItem { + market: DashboardMarket; + code: string; + name: string; + price: number; + change: number; + changeRate: number; +} + +/** + * 시장 지수 API 응답 모델 + */ +export interface DashboardIndicesResponse { + source: "kis"; + tradingEnv: KisTradingEnv; + items: DashboardMarketIndexItem[]; + fetchedAt: string; +} diff --git a/features/dashboard/utils/dashboard-format.ts b/features/dashboard/utils/dashboard-format.ts new file mode 100644 index 0000000..2b25267 --- /dev/null +++ b/features/dashboard/utils/dashboard-format.ts @@ -0,0 +1,66 @@ +/** + * @file features/dashboard/utils/dashboard-format.ts + * @description 대시보드 숫자/색상 표현 유틸 + */ + +const KRW_FORMATTER = new Intl.NumberFormat("ko-KR"); +const PERCENT_FORMATTER = new Intl.NumberFormat("ko-KR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +/** + * 원화 금액을 포맷합니다. + * @param value 숫자 값 + * @returns 쉼표 포맷 문자열 + * @see features/dashboard/components/StatusHeader.tsx 자산/손익 금액 표시 + */ +export function formatCurrency(value: number) { + return KRW_FORMATTER.format(value); +} + +/** + * 퍼센트 값을 포맷합니다. + * @param value 숫자 값 + * @returns 소수점 2자리 퍼센트 문자열 + * @see features/dashboard/components/StatusHeader.tsx 수익률 표시 + */ +export function formatPercent(value: number) { + return `${PERCENT_FORMATTER.format(value)}%`; +} + +/** + * 값의 부호를 포함한 금액 문자열을 만듭니다. + * @param value 숫자 값 + * @returns + 또는 - 부호가 포함된 금액 문자열 + * @see features/dashboard/components/MarketSummary.tsx 전일 대비 수치 표시 + */ +export function formatSignedCurrency(value: number) { + if (value > 0) return `+${formatCurrency(value)}`; + if (value < 0) return `-${formatCurrency(Math.abs(value))}`; + return "0"; +} + +/** + * 값의 부호를 포함한 퍼센트 문자열을 만듭니다. + * @param value 숫자 값 + * @returns + 또는 - 부호가 포함된 퍼센트 문자열 + * @see features/dashboard/components/MarketSummary.tsx 전일 대비율 표시 + */ +export function formatSignedPercent(value: number) { + if (value > 0) return `+${formatPercent(value)}`; + if (value < 0) return `-${formatPercent(Math.abs(value))}`; + return "0.00%"; +} + +/** + * 숫자 값의 상승/하락/보합 텍스트 색상을 반환합니다. + * @param value 숫자 값 + * @returns Tailwind 텍스트 클래스 + * @see features/dashboard/components/HoldingsList.tsx 수익률/손익 색상 적용 + */ +export function getChangeToneClass(value: number) { + if (value > 0) return "text-red-600 dark:text-red-400"; + if (value < 0) return "text-blue-600 dark:text-blue-400"; + return "text-muted-foreground"; +} diff --git a/features/layout/components/user-menu.tsx b/features/layout/components/user-menu.tsx index d102bb3..41ff988 100644 --- a/features/layout/components/user-menu.tsx +++ b/features/layout/components/user-menu.tsx @@ -20,6 +20,12 @@ import { } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; +const SESSION_RELATED_STORAGE_KEYS = [ + "session-storage", + "auth-storage", + "autotrade-kis-runtime-store", +] as const; + interface UserMenuProps { /** Supabase User 객체 */ user: User | null; @@ -39,6 +45,18 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) { if (!user) return null; + /** + * @description 로그아웃 제출 직전에 세션 관련 로컬 스토리지를 정리합니다. + * @see features/auth/actions.ts signout - 서버 세션 종료를 담당합니다. + */ + const clearSessionRelatedStorage = () => { + if (typeof window === "undefined") return; + + for (const key of SESSION_RELATED_STORAGE_KEYS) { + window.localStorage.removeItem(key); + } + }; + return ( @@ -91,7 +109,7 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) { -
+
+ {/* Account No Input */} +
+
+ +
+
+ 계좌번호 +
+ setKisAccountNoInput(e.target.value)} + onFocus={() => setFocusedField("accountNo")} + onBlur={() => setFocusedField(null)} + placeholder="12345678-01" + className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600" + autoComplete="off" + /> +
+ {/* App Secret Input */}
); } + +/** + * @description KIS 계좌번호(8-2) 입력 포맷을 검증합니다. + * @param value 사용자 입력 계좌번호 + * @returns 형식 유효 여부 + * @see features/settings/components/KisAuthForm.tsx handleValidate 인증 전 계좌번호 검사 + */ +function isValidAccountNo(value: string) { + const digits = value.replace(/\D/g, ""); + return digits.length === 10; +} diff --git a/features/trade/components/search/StockSearchForm.tsx b/features/trade/components/search/StockSearchForm.tsx index 313b6fd..77e8e65 100644 --- a/features/trade/components/search/StockSearchForm.tsx +++ b/features/trade/components/search/StockSearchForm.tsx @@ -1,5 +1,5 @@ import type { FormEvent } from "react"; -import { Search } from "lucide-react"; +import { Search, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -24,6 +24,12 @@ export function StockSearchForm({ disabled, isLoading, }: StockSearchFormProps) { + const handleClear = () => { + onKeywordChange(""); + // 포커스 로직이 필요하다면 상위에서 ref를 전달받거나 여기서 ref를 사용해야 함. + // 현재는 단순 값 초기화만 수행. + }; + return ( {/* ========== SEARCH INPUT ========== */} @@ -35,8 +41,19 @@ export function StockSearchForm({ onFocus={onInputFocus} placeholder="종목명 또는 종목코드(6자리)를 입력하세요." autoComplete="off" - className="pl-9 dark:border-brand-700/55 dark:bg-brand-900/28 dark:text-brand-100 dark:placeholder:text-brand-100/55" + className="pl-9 pr-9 dark:border-brand-700/55 dark:bg-brand-900/28 dark:text-brand-100 dark:placeholder:text-brand-100/55" /> + {keyword && ( + + )}
{/* ========== SUBMIT BUTTON ========== */} diff --git a/lib/kis/account.ts b/lib/kis/account.ts new file mode 100644 index 0000000..e90dad1 --- /dev/null +++ b/lib/kis/account.ts @@ -0,0 +1,50 @@ +/** + * @file lib/kis/account.ts + * @description KIS 계좌번호(8-2) 파싱/검증 유틸 + */ + +export interface KisAccountParts { + accountNo: string; + accountProductCode: string; +} + +/** + * 입력된 계좌 문자열을 KIS 표준(8-2)으로 변환합니다. + * @param accountNoInput 계좌번호(예: 12345678-01 또는 1234567801) + * @param accountProductCodeInput 계좌상품코드(2자리, 선택) + * @returns 파싱 성공 시 accountNo/accountProductCode + * @see app/api/kis/domestic/balance/route.ts 잔고 조회 API에서 계좌 파싱에 사용합니다. + */ +export function parseKisAccountParts( + accountNoInput?: string | null, + accountProductCodeInput?: string | null, +): KisAccountParts | null { + const accountDigits = toDigits(accountNoInput); + const productDigits = toDigits(accountProductCodeInput); + + if (accountDigits.length >= 10) { + return { + accountNo: accountDigits.slice(0, 8), + accountProductCode: accountDigits.slice(8, 10), + }; + } + + if (accountDigits.length === 8 && productDigits.length === 2) { + return { + accountNo: accountDigits, + accountProductCode: productDigits, + }; + } + + return null; +} + +/** + * 문자열에서 숫자만 추출합니다. + * @param value 원본 문자열 + * @returns 숫자만 남긴 문자열 + * @see lib/kis/account.ts parseKisAccountParts 입력 정규화 처리 + */ +function toDigits(value?: string | null) { + return (value ?? "").replace(/\D/g, ""); +} diff --git a/lib/kis/dashboard.ts b/lib/kis/dashboard.ts new file mode 100644 index 0000000..86bdcb4 --- /dev/null +++ b/lib/kis/dashboard.ts @@ -0,0 +1,336 @@ +/** + * @file lib/kis/dashboard.ts + * @description 대시보드 전용 KIS 잔고/지수 데이터 어댑터 + */ + +import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks"; +import { kisGet } from "@/lib/kis/client"; +import type { KisCredentialInput } from "@/lib/kis/config"; +import { normalizeTradingEnv } from "@/lib/kis/config"; +import type { KisAccountParts } from "@/lib/kis/account"; + +interface KisBalanceOutput1Row { + pdno?: string; + prdt_name?: string; + hldg_qty?: string; + pchs_avg_pric?: string; + prpr?: string; + evlu_amt?: string; + evlu_pfls_amt?: string; + evlu_pfls_rt?: string; +} + +interface KisBalanceOutput2Row { + dnca_tot_amt?: string; + tot_evlu_amt?: string; + scts_evlu_amt?: string; + evlu_amt_smtl_amt?: string; + evlu_pfls_smtl_amt?: string; + asst_icdc_erng_rt?: string; +} + +interface KisIndexOutputRow { + bstp_nmix_prpr?: string; + bstp_nmix_prdy_vrss?: string; + bstp_nmix_prdy_ctrt?: string; + prdy_vrss_sign?: string; +} + +export interface DomesticBalanceSummary { + totalAmount: number; + cashBalance: number; + totalProfitLoss: number; + totalProfitRate: number; +} + +export interface DomesticHoldingItem { + symbol: string; + name: string; + market: "KOSPI" | "KOSDAQ"; + quantity: number; + averagePrice: number; + currentPrice: number; + evaluationAmount: number; + profitLoss: number; + profitRate: number; +} + +export interface DomesticBalanceResult { + summary: DomesticBalanceSummary; + holdings: DomesticHoldingItem[]; +} + +export interface DomesticMarketIndexResult { + market: "KOSPI" | "KOSDAQ"; + code: string; + name: string; + price: number; + change: number; + changeRate: number; +} + +const MARKET_BY_SYMBOL = new Map( + KOREAN_STOCK_INDEX.map((item) => [item.symbol, item.market] as const), +); + +const INDEX_TARGETS: Array<{ + market: "KOSPI" | "KOSDAQ"; + code: string; + name: string; +}> = [ + { market: "KOSPI", code: "0001", name: "코스피" }, + { market: "KOSDAQ", code: "1001", name: "코스닥" }, +]; + +/** + * KIS 잔고조회 API를 호출해 대시보드 모델로 변환합니다. + * @param account KIS 계좌번호(8-2) 파트 + * @param credentials 사용자 입력 키(선택) + * @returns 대시보드 요약/보유종목 모델 + * @see app/api/kis/domestic/balance/route.ts 대시보드 잔고 API 응답 생성 + */ +export async function getDomesticDashboardBalance( + account: KisAccountParts, + credentials?: KisCredentialInput, +): Promise { + const trId = + normalizeTradingEnv(credentials?.tradingEnv) === "real" + ? "TTTC8434R" + : "VTTC8434R"; + + const response = await kisGet( + "/uapi/domestic-stock/v1/trading/inquire-balance", + trId, + { + CANO: account.accountNo, + ACNT_PRDT_CD: account.accountProductCode, + AFHR_FLPR_YN: "N", + OFL_YN: "", + INQR_DVSN: "02", + UNPR_DVSN: "01", + FUND_STTL_ICLD_YN: "N", + FNCG_AMT_AUTO_RDPT_YN: "N", + PRCS_DVSN: "00", + CTX_AREA_FK100: "", + CTX_AREA_NK100: "", + }, + credentials, + ); + + const holdingRows = parseRows(response.output1); + const summaryRow = parseFirstRow(response.output2); + + const holdings = holdingRows + .map((row) => { + const symbol = (row.pdno ?? "").trim(); + if (!/^\d{6}$/.test(symbol)) return null; + + return { + symbol, + name: (row.prdt_name ?? "").trim() || symbol, + market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI", + quantity: toNumber(row.hldg_qty), + averagePrice: toNumber(row.pchs_avg_pric), + currentPrice: toNumber(row.prpr), + evaluationAmount: toNumber(row.evlu_amt), + profitLoss: toNumber(row.evlu_pfls_amt), + profitRate: toNumber(row.evlu_pfls_rt), + } satisfies DomesticHoldingItem; + }) + .filter((item): item is DomesticHoldingItem => Boolean(item)); + + const cashBalance = toNumber(summaryRow?.dnca_tot_amt); + const holdingsEvalAmount = sumNumbers(holdings.map((item) => item.evaluationAmount)); + const stockEvalAmount = firstPositiveNumber( + toNumber(summaryRow?.scts_evlu_amt), + toNumber(summaryRow?.evlu_amt_smtl_amt), + holdingsEvalAmount, + ); + const totalAmount = firstPositiveNumber( + stockEvalAmount + cashBalance, + toNumber(summaryRow?.tot_evlu_amt), + holdingsEvalAmount + cashBalance, + ); + const totalProfitLoss = firstDefinedNumber( + toOptionalNumber(summaryRow?.evlu_pfls_smtl_amt), + sumNumbers(holdings.map((item) => item.profitLoss)), + ); + const totalProfitRate = firstDefinedNumber( + toOptionalNumber(summaryRow?.asst_icdc_erng_rt), + calcProfitRate(totalProfitLoss, totalAmount), + ); + + return { + summary: { + totalAmount, + cashBalance, + totalProfitLoss, + totalProfitRate, + }, + holdings, + }; +} + +/** + * KOSPI/KOSDAQ 지수를 조회해 대시보드 모델로 변환합니다. + * @param credentials 사용자 입력 키(선택) + * @returns 지수 목록(코스피/코스닥) + * @see app/api/kis/domestic/indices/route.ts 대시보드 지수 API 응답 생성 + */ +export async function getDomesticDashboardIndices( + credentials?: KisCredentialInput, +): Promise { + const results = await Promise.all( + INDEX_TARGETS.map(async (target) => { + const response = await kisGet( + "/uapi/domestic-stock/v1/quotations/inquire-index-price", + "FHPUP02100000", + { + FID_COND_MRKT_DIV_CODE: "U", + FID_INPUT_ISCD: target.code, + }, + credentials, + ); + + const row = parseIndexRow(response.output); + const rawChange = toNumber(row.bstp_nmix_prdy_vrss); + const rawChangeRate = toNumber(row.bstp_nmix_prdy_ctrt); + + return { + market: target.market, + code: target.code, + name: target.name, + price: toNumber(row.bstp_nmix_prpr), + change: normalizeSignedValue(rawChange, row.prdy_vrss_sign), + changeRate: normalizeSignedValue(rawChangeRate, row.prdy_vrss_sign), + } satisfies DomesticMarketIndexResult; + }), + ); + + return results; +} + +/** + * 문자열 숫자를 number로 변환합니다. + * @param value KIS 숫자 문자열 + * @returns 파싱된 숫자(실패 시 0) + * @see lib/kis/dashboard.ts 잔고/지수 필드 공통 파싱 + */ +function toNumber(value?: string) { + if (!value) return 0; + const normalized = value.replaceAll(",", "").trim(); + if (!normalized) return 0; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : 0; +} + +/** + * 문자열 숫자를 number로 변환하되 0도 유효값으로 유지합니다. + * @param value KIS 숫자 문자열 + * @returns 파싱된 숫자 또는 undefined + * @see lib/kis/dashboard.ts 요약값 폴백 순서 계산 + */ +function toOptionalNumber(value?: string) { + if (!value) return undefined; + const normalized = value.replaceAll(",", "").trim(); + if (!normalized) return undefined; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : undefined; +} + +/** + * output 계열 데이터를 배열 형태로 변환합니다. + * @param value KIS output 값 + * @returns 레코드 배열 + * @see lib/kis/dashboard.ts 잔고 output1/output2 파싱 + */ +function parseRows(value: unknown): T[] { + if (Array.isArray(value)) return value as T[]; + if (value && typeof value === "object") return [value as T]; + return []; +} + +/** + * output 계열 데이터의 첫 행을 반환합니다. + * @param value KIS output 값 + * @returns 첫 번째 레코드 + * @see lib/kis/dashboard.ts 잔고 요약(output2) 파싱 + */ +function parseFirstRow(value: unknown) { + const rows = parseRows(value); + return rows[0]; +} + +/** + * 지수 output을 단일 레코드로 정규화합니다. + * @param output KIS output + * @returns 지수 레코드 + * @see lib/kis/dashboard.ts getDomesticDashboardIndices + */ +function parseIndexRow(output: unknown): KisIndexOutputRow { + if (Array.isArray(output) && output[0] && typeof output[0] === "object") { + return output[0] as KisIndexOutputRow; + } + if (output && typeof output === "object") { + return output as KisIndexOutputRow; + } + return {}; +} + +/** + * KIS 부호 코드(1/2 상승, 4/5 하락)를 실제 부호로 반영합니다. + * @param value 변동값 + * @param signCode 부호 코드 + * @returns 부호 적용 숫자 + * @see lib/kis/dashboard.ts getDomesticDashboardIndices + */ +function normalizeSignedValue(value: number, signCode?: string) { + const abs = Math.abs(value); + if (signCode === "4" || signCode === "5") return -abs; + if (signCode === "1" || signCode === "2") return abs; + return value; +} + +/** + * 양수 우선값을 반환합니다. + * @param values 후보 숫자 목록 + * @returns 첫 번째 양수, 없으면 0 + * @see lib/kis/dashboard.ts 요약 금액 폴백 계산 + */ +function firstPositiveNumber(...values: number[]) { + return values.find((value) => value > 0) ?? 0; +} + +/** + * undefined가 아닌 첫 값을 반환합니다. + * @param values 후보 숫자 목록 + * @returns 첫 번째 유효값, 없으면 0 + * @see lib/kis/dashboard.ts 요약 수익률/손익 폴백 계산 + */ +function firstDefinedNumber(...values: Array) { + return values.find((value) => value !== undefined) ?? 0; +} + +/** + * 숫자 배열 합계를 계산합니다. + * @param values 숫자 배열 + * @returns 합계 + * @see lib/kis/dashboard.ts 보유종목 합계 계산 + */ +function sumNumbers(values: number[]) { + return values.reduce((total, value) => total + value, 0); +} + +/** + * 총자산 대비 손익률을 계산합니다. + * @param profit 손익 금액 + * @param totalAmount 총자산 금액 + * @returns 손익률(%) + * @see lib/kis/dashboard.ts 요약 수익률 폴백 계산 + */ +function calcProfitRate(profit: number, totalAmount: number) { + if (totalAmount <= 0) return 0; + const baseAmount = totalAmount - profit; + if (baseAmount <= 0) return 0; + return (profit / baseAmount) * 100; +}