From 12feeb2775fa30b8fa941e87cf0f48529f31fa6b Mon Sep 17 00:00:00 2001 From: "jihoon87.lee" Date: Thu, 12 Feb 2026 17:16:41 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EA=B8=B0=EB=8A=A5=20+=20=EA=B3=84=EC=A2=8C?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/kis/domestic/activity/route.ts | 69 ++ app/api/kis/validate-profile/route.ts | 235 +++++++ features/dashboard/apis/dashboard.api.ts | 29 + .../dashboard/components/ActivitySection.tsx | 307 ++++++++ .../components/DashboardAccessGate.tsx | 5 +- .../components/DashboardContainer.tsx | 16 + .../dashboard/components/HoldingsList.tsx | 11 +- .../dashboard/components/MarketSummary.tsx | 4 +- .../dashboard/components/StatusHeader.tsx | 57 +- .../components/StockDetailPreview.tsx | 10 +- .../dashboard/hooks/use-dashboard-data.ts | 26 +- features/dashboard/types/dashboard.types.ts | 72 ++ features/settings/apis/kis-auth.api.ts | 17 +- features/settings/components/KisAuthForm.tsx | 141 ++-- .../settings/components/KisProfileForm.tsx | 218 ++++++ .../settings/components/SettingsContainer.tsx | 57 +- .../settings/store/use-kis-runtime-store.ts | 55 +- .../components/guards/TradeAccessGate.tsx | 4 +- features/trade/types/trade.types.ts | 12 + lib/kis/dashboard.ts | 658 +++++++++++++++++- 20 files changed, 1847 insertions(+), 156 deletions(-) create mode 100644 app/api/kis/domestic/activity/route.ts create mode 100644 app/api/kis/validate-profile/route.ts create mode 100644 features/dashboard/components/ActivitySection.tsx create mode 100644 features/settings/components/KisProfileForm.tsx diff --git a/app/api/kis/domestic/activity/route.ts b/app/api/kis/domestic/activity/route.ts new file mode 100644 index 0000000..85ecc46 --- /dev/null +++ b/app/api/kis/domestic/activity/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import type { DashboardActivityResponse } from "@/features/dashboard/types/dashboard.types"; +import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; +import { getDomesticDashboardActivity } from "@/lib/kis/dashboard"; +import { + readKisAccountParts, + readKisCredentialsFromHeaders, +} from "@/app/api/kis/domestic/_shared"; + +/** + * @file app/api/kis/domestic/activity/route.ts + * @description 국내주식 주문내역/매매일지 조회 API + */ + +/** + * 대시보드 하단(주문내역/매매일지) 조회 API + * @returns 주문내역 목록 + 매매일지 목록/요약 + * @remarks UI 흐름: DashboardContainer -> useDashboardData -> /api/kis/domestic/activity -> ActivitySection 렌더링 + * @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다. + * @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 섹션 렌더링 + */ +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 getDomesticDashboardActivity(account, credentials); + const response: DashboardActivityResponse = { + source: "kis", + tradingEnv: normalizeTradingEnv(credentials.tradingEnv), + orders: result.orders, + tradeJournal: result.tradeJournal, + journalSummary: result.journalSummary, + warnings: result.warnings, + 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/validate-profile/route.ts b/app/api/kis/validate-profile/route.ts new file mode 100644 index 0000000..6f69d0e --- /dev/null +++ b/app/api/kis/validate-profile/route.ts @@ -0,0 +1,235 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types"; +import { parseKisAccountParts } from "@/lib/kis/account"; +import { kisGet } from "@/lib/kis/client"; +import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config"; +import { validateKisCredentialInput } from "@/lib/kis/request"; +import { getKisAccessToken } from "@/lib/kis/token"; + +interface KisProfileValidateRequestBody { + appKey?: string; + appSecret?: string; + tradingEnv?: string; + accountNo?: string; +} + +interface BalanceValidationPreset { + inqrDvsn: "01" | "02"; + prcsDvsn: "00" | "01"; +} + +const BALANCE_VALIDATION_PRESETS: BalanceValidationPreset[] = [ + { + // 명세 기본 요청값 + inqrDvsn: "01", + prcsDvsn: "01", + }, + { + // 일부 계좌/환경 호환값 + inqrDvsn: "02", + prcsDvsn: "00", + }, +]; + +/** + * @file app/api/kis/validate-profile/route.ts + * @description 한국투자증권 계좌번호를 검증합니다. + */ + +/** + * @description 앱키/앱시크릿키 + 계좌번호 유효성을 검증합니다. + * @remarks UI 흐름: /settings -> KisProfileForm 확인 버튼 -> /api/kis/validate-profile -> store 반영 -> 대시보드 상태 확장 + * @see features/settings/components/KisProfileForm.tsx 계좌 확인 버튼에서 호출합니다. + * @see features/settings/apis/kis-auth.api.ts validateKisProfile 클라이언트 API 함수 + */ +export async function POST(request: NextRequest) { + let body: KisProfileValidateRequestBody = {}; + + try { + body = (await request.json()) as KisProfileValidateRequestBody; + } catch { + return NextResponse.json( + { + ok: false, + tradingEnv: "mock", + message: "요청 본문(JSON)을 읽을 수 없습니다.", + } satisfies Pick, + { status: 400 }, + ); + } + + const credentials: KisCredentialInput = { + appKey: body.appKey?.trim(), + appSecret: body.appSecret?.trim(), + tradingEnv: normalizeTradingEnv(body.tradingEnv), + }; + + const tradingEnv = normalizeTradingEnv(credentials.tradingEnv); + + const invalidCredentialMessage = validateKisCredentialInput(credentials); + if (invalidCredentialMessage) { + return NextResponse.json( + { + ok: false, + tradingEnv, + message: invalidCredentialMessage, + } satisfies Pick, + { status: 400 }, + ); + } + + const accountNoInput = (body.accountNo ?? "").trim(); + + if (!accountNoInput) { + return NextResponse.json( + { + ok: false, + tradingEnv, + message: "계좌번호를 입력해 주세요.", + } satisfies Pick, + { status: 400 }, + ); + } + + const accountParts = parseKisAccountParts(accountNoInput); + if (!accountParts) { + return NextResponse.json( + { + ok: false, + tradingEnv, + message: "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.", + } satisfies Pick, + { status: 400 }, + ); + } + + try { + // 1) 토큰 발급으로 앱키/시크릿 사전 검증 + try { + await getKisAccessToken(credentials); + } catch (error) { + throw new Error(`앱키 검증 실패: ${toErrorMessage(error)}`); + } + + // 2) 계좌 유효성 검증 (실제 계좌 조회 API) + try { + await validateAccountByBalanceApi( + accountParts.accountNo, + accountParts.accountProductCode, + credentials, + ); + } catch (error) { + throw new Error(`계좌 검증 실패: ${toErrorMessage(error)}`); + } + + const normalizedAccountNo = `${accountParts.accountNo}-${accountParts.accountProductCode}`; + + return NextResponse.json({ + ok: true, + tradingEnv, + message: "계좌번호 검증이 완료되었습니다.", + account: { + normalizedAccountNo, + }, + } satisfies DashboardKisProfileValidateResponse); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "계좌 검증 중 오류가 발생했습니다."; + + return NextResponse.json( + { + ok: false, + tradingEnv, + message, + } satisfies Pick, + { status: 400 }, + ); + } +} + +/** + * @description 계좌번호로 잔고 조회 API를 호출해 유효성을 확인합니다. + * @param accountNo 계좌번호 앞 8자리 + * @param accountProductCode 계좌번호 뒤 2자리 + * @param credentials KIS 인증 정보 + * @see app/api/kis/validate-profile/route.ts POST + */ +async function validateAccountByBalanceApi( + accountNo: string, + accountProductCode: string, + credentials: KisCredentialInput, +) { + const trId = normalizeTradingEnv(credentials.tradingEnv) === "real" ? "TTTC8434R" : "VTTC8434R"; + const attemptErrors: string[] = []; + + for (const preset of BALANCE_VALIDATION_PRESETS) { + try { + const response = await kisGet( + "/uapi/domestic-stock/v1/trading/inquire-balance", + trId, + { + CANO: accountNo, + ACNT_PRDT_CD: accountProductCode, + AFHR_FLPR_YN: "N", + OFL_YN: "", + INQR_DVSN: preset.inqrDvsn, + UNPR_DVSN: "01", + FUND_STTL_ICLD_YN: "N", + FNCG_AMT_AUTO_RDPT_YN: "N", + PRCS_DVSN: preset.prcsDvsn, + CTX_AREA_FK100: "", + CTX_AREA_NK100: "", + }, + credentials, + ); + + validateInquireBalanceResponse(response); + return; + } catch (error) { + attemptErrors.push( + `INQR_DVSN=${preset.inqrDvsn}, PRCS_DVSN=${preset.prcsDvsn}: ${toErrorMessage(error)}`, + ); + } + } + + throw new Error( + `계좌 확인 요청이 모두 실패했습니다. ${attemptErrors.join(" | ")}`, + ); +} + +/** + * @description 주식잔고조회 응답 구조를 최소 검증합니다. + * @param response KIS 원본 응답 + * @see app/api/kis/validate-profile/route.ts validateAccountByBalanceApi + */ +function validateInquireBalanceResponse( + response: { + output1?: unknown; + output2?: unknown; + }, +) { + const output1Ok = + Array.isArray(response.output1) || + (response.output1 !== null && typeof response.output1 === "object"); + const output2Ok = + Array.isArray(response.output2) || + (response.output2 !== null && typeof response.output2 === "object"); + + if (!output1Ok && !output2Ok) { + throw new Error("응답에 output1/output2가 없습니다. 요청 파라미터를 확인해 주세요."); + } +} + +/** + * @description Error 객체를 사용자 표시용 문자열로 변환합니다. + * @param error unknown 에러 + * @returns 메시지 문자열 + * @see app/api/kis/validate-profile/route.ts POST + */ +function toErrorMessage(error: unknown) { + return error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다."; +} diff --git a/features/dashboard/apis/dashboard.api.ts b/features/dashboard/apis/dashboard.api.ts index 11a2d95..a5c42f2 100644 --- a/features/dashboard/apis/dashboard.api.ts +++ b/features/dashboard/apis/dashboard.api.ts @@ -1,5 +1,6 @@ import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import type { + DashboardActivityResponse, DashboardBalanceResponse, DashboardIndicesResponse, } from "@/features/dashboard/types/dashboard.types"; @@ -65,6 +66,34 @@ export async function fetchDashboardIndices( return payload as DashboardIndicesResponse; } +/** + * 주문내역/매매일지(활동 데이터)를 조회합니다. + * @param credentials KIS 인증 정보 + * @returns 활동 데이터 응답 + * @see app/api/kis/domestic/activity/route.ts 서버 라우트 + */ +export async function fetchDashboardActivity( + credentials: KisRuntimeCredentials, +): Promise { + const response = await fetch("/api/kis/domestic/activity", { + method: "GET", + headers: buildKisRequestHeaders(credentials), + cache: "no-store", + }); + + const payload = (await response.json()) as + | DashboardActivityResponse + | { error?: string }; + + if (!response.ok) { + throw new Error( + "error" in payload ? payload.error : "활동 데이터 조회 중 오류가 발생했습니다.", + ); + } + + return payload as DashboardActivityResponse; +} + /** * 대시보드 API 공통 헤더를 구성합니다. * @param credentials KIS 인증 정보 diff --git a/features/dashboard/components/ActivitySection.tsx b/features/dashboard/components/ActivitySection.tsx new file mode 100644 index 0000000..4aec0e8 --- /dev/null +++ b/features/dashboard/components/ActivitySection.tsx @@ -0,0 +1,307 @@ +import { AlertCircle, ClipboardList, FileText } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { + DashboardActivityResponse, + DashboardTradeSide, +} from "@/features/dashboard/types/dashboard.types"; +import { + formatCurrency, + formatPercent, + getChangeToneClass, +} from "@/features/dashboard/utils/dashboard-format"; +import { cn } from "@/lib/utils"; + +interface ActivitySectionProps { + activity: DashboardActivityResponse | null; + isLoading: boolean; + error: string | null; +} + +/** + * @description 대시보드 하단 주문내역/매매일지 섹션입니다. + * @remarks UI 흐름: DashboardContainer -> ActivitySection -> tabs(주문내역/매매일지) -> 리스트 렌더링 + * @see features/dashboard/components/DashboardContainer.tsx 하단 영역에서 호출합니다. + * @see app/api/kis/domestic/activity/route.ts 주문내역/매매일지 데이터 소스 + */ +export function ActivitySection({ activity, isLoading, error }: ActivitySectionProps) { + const orders = activity?.orders ?? []; + const journalRows = activity?.tradeJournal ?? []; + const summary = activity?.journalSummary; + const warnings = activity?.warnings ?? []; + + return ( + + + {/* ========== TITLE ========== */} + + + 주문내역 · 매매일지 + + + 최근 주문 체결 내역과 실현손익 기록을 확인합니다. + + + + + {isLoading && !activity && ( +

+ 주문내역/매매일지를 불러오는 중입니다. +

+ )} + + {error && ( +

+ + {error} +

+ )} + + {warnings.length > 0 && ( +
+ {warnings.map((warning) => ( + + + {warning} + + ))} +
+ )} + + {/* ========== TABS ========== */} + + + 주문내역 {orders.length}건 + 매매일지 {journalRows.length}건 + + + +
+
+ 일시 + 종목 + 주문 + 체결 + 평균체결가 + 상태 +
+ + + {orders.length === 0 ? ( +

+ 표시할 주문내역이 없습니다. +

+ ) : ( +
+ {orders.map((order) => ( +
+ {/* ========== ORDER DATETIME ========== */} +
+

{order.orderDate}

+

{order.orderTime}

+
+ + {/* ========== STOCK INFO ========== */} +
+

{order.name}

+

+ {order.symbol} · {getSideLabel(order.side)} +

+
+ + {/* ========== ORDER INFO ========== */} +
+

수량 {order.orderQuantity.toLocaleString("ko-KR")}주

+

+ {order.orderTypeName} · {formatCurrency(order.orderPrice)}원 +

+
+ + {/* ========== FILLED INFO ========== */} +
+

체결 {order.filledQuantity.toLocaleString("ko-KR")}주

+

+ 금액 {formatCurrency(order.filledAmount)}원 +

+
+ + {/* ========== AVG PRICE ========== */} +
+ {formatCurrency(order.averageFilledPrice)}원 +
+ + {/* ========== STATUS ========== */} +
+ 0 + ? "border-amber-300 text-amber-700 dark:border-amber-700 dark:text-amber-300" + : "border-emerald-300 text-emerald-700 dark:border-emerald-700 dark:text-emerald-300", + )} + > + {order.isCanceled + ? "취소" + : order.remainingQuantity > 0 + ? "미체결" + : "체결완료"} + +
+
+ ))} +
+ )} +
+
+
+ + + {/* ========== JOURNAL SUMMARY ========== */} +
+ + + + +
+ +
+
+ 일자 + 종목 + 매매구분 + 매수/매도금액 + 실현손익(률) + 비용 +
+ + + {journalRows.length === 0 ? ( +

+ 표시할 매매일지가 없습니다. +

+ ) : ( +
+ {journalRows.map((row) => { + const toneClass = getChangeToneClass(row.realizedProfit); + return ( +
+

{row.tradeDate}

+
+

{row.name}

+

{row.symbol}

+
+

+ {getSideLabel(row.side)} +

+

+ 매수 {formatCurrency(row.buyAmount)}원 / 매도 {formatCurrency(row.sellAmount)}원 +

+

+ {formatCurrency(row.realizedProfit)}원 ({formatPercent(row.realizedRate)}) +

+

+ 수수료 {formatCurrency(row.fee)}원 +
+ 세금 {formatCurrency(row.tax)}원 +

+
+ ); + })} +
+ )} +
+
+
+
+ + {!isLoading && !error && !activity && ( +

+ + 활동 데이터가 없습니다. +

+ )} +
+
+ ); +} + +interface SummaryMetricProps { + label: string; + value: string; + toneClass?: string; +} + +/** + * @description 매매일지 요약 지표 카드입니다. + * @param label 지표명 + * @param value 지표값 + * @param toneClass 값 색상 클래스 + * @see features/dashboard/components/ActivitySection.tsx 매매일지 상단 요약 표시 + */ +function SummaryMetric({ label, value, toneClass }: SummaryMetricProps) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +/** + * @description 매수/매도 라벨 텍스트를 반환합니다. + * @param side 매수/매도 구분값 + * @returns 라벨 문자열 + * @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 표시 + */ +function getSideLabel(side: DashboardTradeSide) { + if (side === "buy") return "매수"; + if (side === "sell") return "매도"; + return "기타"; +} + +/** + * @description 매수/매도 라벨 색상 클래스를 반환합니다. + * @param side 매수/매도 구분값 + * @returns Tailwind 텍스트 클래스 + * @see features/dashboard/components/ActivitySection.tsx 매매구분 표시 + */ +function getSideToneClass(side: DashboardTradeSide) { + if (side === "buy") return "text-red-600 dark:text-red-400"; + if (side === "sell") return "text-blue-600 dark:text-blue-400"; + return "text-muted-foreground"; +} diff --git a/features/dashboard/components/DashboardAccessGate.tsx b/features/dashboard/components/DashboardAccessGate.tsx index 1e90234..21a7b3e 100644 --- a/features/dashboard/components/DashboardAccessGate.tsx +++ b/features/dashboard/components/DashboardAccessGate.tsx @@ -18,11 +18,10 @@ export function DashboardAccessGate({ canAccess }: DashboardAccessGateProps) {
{/* ========== UNVERIFIED NOTICE ========== */}

- 대시보드를 보려면 KIS API 인증이 필요합니다. + 대시보드를 보려면 한국투자증권 연결이 필요합니다.

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

{/* ========== ACTION ========== */} diff --git a/features/dashboard/components/DashboardContainer.tsx b/features/dashboard/components/DashboardContainer.tsx index 25aef94..1d617da 100644 --- a/features/dashboard/components/DashboardContainer.tsx +++ b/features/dashboard/components/DashboardContainer.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react"; import { useShallow } from "zustand/react/shallow"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ActivitySection } from "@/features/dashboard/components/ActivitySection"; import { DashboardAccessGate } from "@/features/dashboard/components/DashboardAccessGate"; import { DashboardSkeleton } from "@/features/dashboard/components/DashboardSkeleton"; import { HoldingsList } from "@/features/dashboard/components/HoldingsList"; @@ -22,6 +23,8 @@ export function DashboardContainer() { const { verifiedCredentials, isKisVerified, + isKisProfileVerified, + verifiedAccountNo, _hasHydrated, wsApprovalKey, wsUrl, @@ -29,6 +32,8 @@ export function DashboardContainer() { useShallow((state) => ({ verifiedCredentials: state.verifiedCredentials, isKisVerified: state.isKisVerified, + isKisProfileVerified: state.isKisProfileVerified, + verifiedAccountNo: state.verifiedAccountNo, _hasHydrated: state._hasHydrated, wsApprovalKey: state.wsApprovalKey, wsUrl: state.wsUrl, @@ -38,6 +43,7 @@ export function DashboardContainer() { const canAccess = isKisVerified && Boolean(verifiedCredentials); const { + activity, balance, indices, selectedHolding, @@ -45,6 +51,7 @@ export function DashboardContainer() { setSelectedSymbol, isLoading, isRefreshing, + activityError, balanceError, indicesError, lastUpdatedAt, @@ -80,6 +87,8 @@ export function DashboardContainer() { summary={balance?.summary ?? null} isKisRestConnected={isKisRestConnected} isWebSocketReady={Boolean(wsApprovalKey && wsUrl)} + isProfileVerified={isKisProfileVerified} + verifiedAccountNo={verifiedAccountNo} isRefreshing={isRefreshing} lastUpdatedAt={lastUpdatedAt} onRefresh={() => { @@ -110,6 +119,13 @@ export function DashboardContainer() { /> + + {/* ========== ACTIVITY SECTION ========== */} +
); } diff --git a/features/dashboard/components/HoldingsList.tsx b/features/dashboard/components/HoldingsList.tsx index ae26aa8..675e0cb 100644 --- a/features/dashboard/components/HoldingsList.tsx +++ b/features/dashboard/components/HoldingsList.tsx @@ -103,12 +103,15 @@ export function HoldingsList({ {/* ========== ROW BOTTOM ========== */} -
+
- 평가금액 {formatCurrency(holding.evaluationAmount)}원 + 평균 매수가 {formatCurrency(holding.averagePrice)}원 - - 손익 {formatCurrency(holding.profitLoss)}원 + + 현재 평가금액 {formatCurrency(holding.evaluationAmount)}원 + + + 현재 손익 {formatCurrency(holding.profitLoss)}원
diff --git a/features/dashboard/components/MarketSummary.tsx b/features/dashboard/components/MarketSummary.tsx index 3679b21..8b0c996 100644 --- a/features/dashboard/components/MarketSummary.tsx +++ b/features/dashboard/components/MarketSummary.tsx @@ -32,10 +32,10 @@ export function MarketSummary({ items, isLoading, error }: MarketSummaryProps) { {/* ========== TITLE ========== */} - 시장 요약 + 시장 지수 - KOSPI/KOSDAQ 주요 지수 변동을 보여줍니다. + 코스피/코스닥 지수 움직임을 보여줍니다. diff --git a/features/dashboard/components/StatusHeader.tsx b/features/dashboard/components/StatusHeader.tsx index 01449e1..ee4e844 100644 --- a/features/dashboard/components/StatusHeader.tsx +++ b/features/dashboard/components/StatusHeader.tsx @@ -14,6 +14,8 @@ interface StatusHeaderProps { summary: DashboardBalanceSummary | null; isKisRestConnected: boolean; isWebSocketReady: boolean; + isProfileVerified: boolean; + verifiedAccountNo: string | null; isRefreshing: boolean; lastUpdatedAt: string | null; onRefresh: () => void; @@ -27,6 +29,8 @@ export function StatusHeader({ summary, isKisRestConnected, isWebSocketReady, + isProfileVerified, + verifiedAccountNo, isRefreshing, lastUpdatedAt, onRefresh, @@ -53,22 +57,31 @@ export function StatusHeader({

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

+

+ 실제 자산 {summary ? `${formatCurrency(summary.netAssetAmount)}원` : "-"} +

{/* ========== PROFIT/LOSS ========== */}
-

실시간 손익

+

현재 손익

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

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

+

+ 현재 평가금액 {summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"} +

+

+ 총 매수금액 {summary ? `${formatCurrency(summary.purchaseAmount)}원` : "-"} +

{/* ========== CONNECTION STATUS ========== */}
-

시스템 상태

+

연결 상태

- REST {isKisRestConnected ? "연결됨" : "연결 끊김"} + 서버 {isKisRestConnected ? "연결됨" : "연결 끊김"} - WS {isWebSocketReady ? "준비됨" : "미연결"} + 실시간 시세 {isWebSocketReady ? "연결됨" : "미연결"} + + + + 계좌 인증 {isProfileVerified ? "완료" : "미완료"}

- 마지막 갱신 {updatedLabel} + 마지막 업데이트 {updatedLabel} +

+

+ 계좌 {maskAccountNo(verifiedAccountNo)} +

+

+ 대출금 {summary ? `${formatCurrency(summary.loanAmount)}원` : "-"}

@@ -110,7 +140,7 @@ export function StatusHeader({ - 새로고침 + 지금 다시 불러오기 @@ -126,3 +156,16 @@ export function StatusHeader({ ); } + +/** + * @description 계좌번호를 마스킹해 표시합니다. + * @param value 계좌번호(8-2) + * @returns 마스킹 문자열 + * @see features/dashboard/components/StatusHeader.tsx 시스템 상태 영역 계좌 표시 + */ +function maskAccountNo(value: string | null) { + if (!value) return "-"; + const digits = value.replace(/\D/g, ""); + if (digits.length !== 10) return "********"; + return "********-**"; +} diff --git a/features/dashboard/components/StockDetailPreview.tsx b/features/dashboard/components/StockDetailPreview.tsx index 1eea0a2..2191bed 100644 --- a/features/dashboard/components/StockDetailPreview.tsx +++ b/features/dashboard/components/StockDetailPreview.tsx @@ -33,10 +33,10 @@ export function StockDetailPreview({ - 종목 상세 미리보기 + 선택 종목 정보 - 보유 종목을 선택하면 상세 요약이 표시됩니다. + 보유 종목을 선택하면 자세한 정보가 표시됩니다. @@ -58,7 +58,7 @@ export function StockDetailPreview({ {/* ========== TITLE ========== */} - 종목 상세 미리보기 + 선택 종목 정보 {holding.name} ({holding.symbol}) · {holding.market} @@ -77,7 +77,7 @@ export function StockDetailPreview({ valueClassName={profitToneClass} /> @@ -105,7 +105,7 @@ export function StockDetailPreview({

- 간편 주문(준비 중) + 빠른 주문(준비 중)

향후 이 영역에서 선택 종목의 빠른 매수/매도 기능을 제공합니다. diff --git a/features/dashboard/hooks/use-dashboard-data.ts b/features/dashboard/hooks/use-dashboard-data.ts index eb89570..d11c895 100644 --- a/features/dashboard/hooks/use-dashboard-data.ts +++ b/features/dashboard/hooks/use-dashboard-data.ts @@ -3,16 +3,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import { + fetchDashboardActivity, fetchDashboardBalance, fetchDashboardIndices, } from "@/features/dashboard/apis/dashboard.api"; import type { + DashboardActivityResponse, DashboardBalanceResponse, DashboardHoldingItem, DashboardIndicesResponse, } from "@/features/dashboard/types/dashboard.types"; interface UseDashboardDataResult { + activity: DashboardActivityResponse | null; balance: DashboardBalanceResponse | null; indices: DashboardIndicesResponse["items"]; selectedHolding: DashboardHoldingItem | null; @@ -20,6 +23,7 @@ interface UseDashboardDataResult { setSelectedSymbol: (symbol: string) => void; isLoading: boolean; isRefreshing: boolean; + activityError: string | null; balanceError: string | null; indicesError: string | null; lastUpdatedAt: string | null; @@ -39,11 +43,13 @@ const POLLING_INTERVAL_MS = 60_000; export function useDashboardData( credentials: KisRuntimeCredentials | null, ): UseDashboardDataResult { + const [activity, setActivity] = useState(null); 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 [activityError, setActivityError] = useState(null); const [balanceError, setBalanceError] = useState(null); const [indicesError, setIndicesError] = useState(null); const [lastUpdatedAt, setLastUpdatedAt] = useState(null); @@ -73,14 +79,18 @@ export function useDashboardData( const tasks: [ Promise, Promise, + Promise, ] = [ hasAccountNo ? fetchDashboardBalance(credentials) : Promise.resolve(null), fetchDashboardIndices(credentials), + hasAccountNo + ? fetchDashboardActivity(credentials) + : Promise.resolve(null), ]; - const [balanceResult, indicesResult] = await Promise.allSettled(tasks); + const [balanceResult, indicesResult, activityResult] = await Promise.allSettled(tasks); if (requestSeq !== requestSeqRef.current) return; let hasAnySuccess = false; @@ -90,6 +100,10 @@ export function useDashboardData( setBalanceError( "계좌번호가 없어 잔고를 조회할 수 없습니다. 설정에서 계좌번호(8-2)를 입력해 주세요.", ); + setActivity(null); + setActivityError( + "계좌번호가 없어 주문내역/매매일지를 조회할 수 없습니다. 설정에서 계좌번호(8-2)를 입력해 주세요.", + ); setSelectedSymbolState(null); } else if (balanceResult.status === "fulfilled") { hasAnySuccess = true; @@ -108,6 +122,14 @@ export function useDashboardData( setBalanceError(balanceResult.reason instanceof Error ? balanceResult.reason.message : "잔고 조회에 실패했습니다."); } + if (hasAccountNo && activityResult.status === "fulfilled") { + hasAnySuccess = true; + setActivity(activityResult.value); + setActivityError(null); + } else if (hasAccountNo && activityResult.status === "rejected") { + setActivityError(activityResult.reason instanceof Error ? activityResult.reason.message : "주문내역/매매일지 조회에 실패했습니다."); + } + if (indicesResult.status === "fulfilled") { hasAnySuccess = true; setIndices(indicesResult.value.items); @@ -167,6 +189,7 @@ export function useDashboardData( }, []); return { + activity, balance, indices, selectedHolding, @@ -174,6 +197,7 @@ export function useDashboardData( setSelectedSymbol, isLoading, isRefreshing, + activityError, balanceError, indicesError, lastUpdatedAt, diff --git a/features/dashboard/types/dashboard.types.ts b/features/dashboard/types/dashboard.types.ts index a1bc1a5..6addf92 100644 --- a/features/dashboard/types/dashboard.types.ts +++ b/features/dashboard/types/dashboard.types.ts @@ -15,6 +15,10 @@ export interface DashboardBalanceSummary { cashBalance: number; totalProfitLoss: number; totalProfitRate: number; + netAssetAmount: number; + evaluationAmount: number; + purchaseAmount: number; + loanAmount: number; } /** @@ -32,6 +36,61 @@ export interface DashboardHoldingItem { profitRate: number; } +/** + * 주문/매매 공통 매수/매도 구분 + */ +export type DashboardTradeSide = "buy" | "sell" | "unknown"; + +/** + * 대시보드 주문내역 항목 + */ +export interface DashboardOrderHistoryItem { + orderDate: string; + orderTime: string; + orderNo: string; + symbol: string; + name: string; + side: DashboardTradeSide; + orderTypeName: string; + orderPrice: number; + orderQuantity: number; + filledQuantity: number; + filledAmount: number; + averageFilledPrice: number; + remainingQuantity: number; + isCanceled: boolean; +} + +/** + * 대시보드 매매일지 항목 + */ +export interface DashboardTradeJournalItem { + tradeDate: string; + symbol: string; + name: string; + side: DashboardTradeSide; + buyQuantity: number; + buyAmount: number; + sellQuantity: number; + sellAmount: number; + realizedProfit: number; + realizedRate: number; + fee: number; + tax: number; +} + +/** + * 대시보드 매매일지 요약 + */ +export interface DashboardTradeJournalSummary { + totalRealizedProfit: number; + totalRealizedRate: number; + totalBuyAmount: number; + totalSellAmount: number; + totalFee: number; + totalTax: number; +} + /** * 계좌 잔고 API 응답 모델 */ @@ -64,3 +123,16 @@ export interface DashboardIndicesResponse { items: DashboardMarketIndexItem[]; fetchedAt: string; } + +/** + * 주문내역/매매일지 API 응답 모델 + */ +export interface DashboardActivityResponse { + source: "kis"; + tradingEnv: KisTradingEnv; + orders: DashboardOrderHistoryItem[]; + tradeJournal: DashboardTradeJournalItem[]; + journalSummary: DashboardTradeJournalSummary; + warnings: string[]; + fetchedAt: string; +} diff --git a/features/settings/apis/kis-auth.api.ts b/features/settings/apis/kis-auth.api.ts index f28dcbe..722bf9b 100644 --- a/features/settings/apis/kis-auth.api.ts +++ b/features/settings/apis/kis-auth.api.ts @@ -1,5 +1,6 @@ import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import type { + DashboardKisProfileValidateResponse, DashboardKisRevokeResponse, DashboardKisValidateResponse, DashboardKisWsApprovalResponse, @@ -43,7 +44,7 @@ export async function validateKisCredentials( return postKisAuthApi( "/api/kis/validate", credentials, - "API 키 검증에 실패했습니다.", + "앱키 검증에 실패했습니다.", ); } @@ -80,3 +81,17 @@ export async function fetchKisWebSocketApproval( return payload; } + +/** + * @description 계좌번호를 검증합니다. + * @see app/api/kis/validate-profile/route.ts + */ +export async function validateKisProfile( + credentials: KisRuntimeCredentials, +): Promise { + return postKisAuthApi( + "/api/kis/validate-profile", + credentials, + "계좌 검증에 실패했습니다.", + ); +} diff --git a/features/settings/components/KisAuthForm.tsx b/features/settings/components/KisAuthForm.tsx index e1a58d5..4869c61 100644 --- a/features/settings/components/KisAuthForm.tsx +++ b/features/settings/components/KisAuthForm.tsx @@ -14,7 +14,6 @@ import { CheckCircle2, XCircle, Lock, - CreditCard, Sparkles, Zap, Activity, @@ -22,23 +21,22 @@ import { import { InlineSpinner } from "@/components/ui/loading-spinner"; /** - * @description KIS 인증 입력 폼 (Minimal Redesign v4) - * - User Feedback: "입력창이 너무 길어", "파란색/하늘색 제거해" - * - Compact Width: max-w-lg + mx-auto - * - Monochrome Mock Mode: Blue -> Zinc/Gray + * @description 한국투자증권 앱키/앱시크릿키 인증 폼입니다. + * @remarks UI 흐름: /settings -> 앱키/앱시크릿키 입력 -> 연결 확인 버튼 -> /api/kis/validate -> 연결 상태 반영 + * @see app/api/kis/validate/route.ts 앱키 검증 API + * @see features/settings/store/use-kis-runtime-store.ts 인증 상태 저장소 */ export function KisAuthForm() { const { kisTradingEnvInput, kisAppKeyInput, kisAppSecretInput, - kisAccountNoInput, + verifiedAccountNo, verifiedCredentials, isKisVerified, setKisTradingEnvInput, setKisAppKeyInput, setKisAppSecretInput, - setKisAccountNoInput, setVerifiedKisSession, invalidateKisVerification, clearKisRuntimeSession, @@ -47,13 +45,12 @@ export function KisAuthForm() { kisTradingEnvInput: state.kisTradingEnvInput, kisAppKeyInput: state.kisAppKeyInput, kisAppSecretInput: state.kisAppSecretInput, - kisAccountNoInput: state.kisAccountNoInput, + verifiedAccountNo: state.verifiedAccountNo, verifiedCredentials: state.verifiedCredentials, isKisVerified: state.isKisVerified, setKisTradingEnvInput: state.setKisTradingEnvInput, setKisAppKeyInput: state.setKisAppKeyInput, setKisAppSecretInput: state.setKisAppSecretInput, - setKisAccountNoInput: state.setKisAccountNoInput, setVerifiedKisSession: state.setVerifiedKisSession, invalidateKisVerification: state.invalidateKisVerification, clearKisRuntimeSession: state.clearKisRuntimeSession, @@ -66,9 +63,7 @@ export function KisAuthForm() { const [isRevoking, startRevokeTransition] = useTransition(); // 입력 필드 Focus 상태 관리를 위한 State - const [focusedField, setFocusedField] = useState< - "appKey" | "appSecret" | "accountNo" | null - >(null); + const [focusedField, setFocusedField] = useState<"appKey" | "appSecret" | null>(null); function handleValidate() { startValidateTransition(async () => { @@ -78,23 +73,15 @@ export function KisAuthForm() { const appKey = kisAppKeyInput.trim(); const appSecret = kisAppSecretInput.trim(); - const accountNo = kisAccountNoInput.trim(); - if (!appKey || !appSecret) { - throw new Error("App Key와 App Secret을 모두 입력해 주세요."); - } - - if (accountNo && !isValidAccountNo(accountNo)) { - throw new Error( - "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.", - ); + throw new Error("앱키와 앱시크릿키를 모두 입력해 주세요."); } const credentials = { appKey, appSecret, tradingEnv: kisTradingEnvInput, - accountNo, + accountNo: verifiedAccountNo ?? "", }; const result = await validateKisCredentials(credentials); @@ -107,7 +94,7 @@ export function KisAuthForm() { setErrorMessage( err instanceof Error ? err.message - : "API 키 검증 중 오류가 발생했습니다.", + : "앱키 확인 중 오류가 발생했습니다.", ); } }); @@ -136,7 +123,7 @@ export function KisAuthForm() { } return ( -

+
{/* Inner Content Container - Compact spacing */}
{/* Header Section */} @@ -147,16 +134,16 @@ export function KisAuthForm() {

- KIS API Key Connection + 한국투자증권 앱키 연결 {isKisVerified && ( - Connected + 연결됨 )}

- 한국투자증권에서 발급받은 API 키를 입력해 주세요. + 한국투자증권 Open API에서 발급받은 앱키/앱시크릿키를 입력해 주세요.

@@ -206,7 +193,7 @@ export function KisAuthForm() { {/* Input Fields Section (Compact Stacked Layout) */}
- {/* App Key Input */} + {/* ========== APP KEY INPUT ========== */}
-
- App Key -
- + 앱키 +
+ setKisAppKeyInput(e.target.value)} - onFocus={() => setFocusedField("appKey")} - onBlur={() => setFocusedField(null)} - placeholder="App Key 입력" - className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600" - autoComplete="off" - /> + onFocus={() => setFocusedField("appKey")} + onBlur={() => setFocusedField(null)} + placeholder="한국투자증권 앱키 입력" + 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" + />
- {/* 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 */} + {/* ========== APP SECRET INPUT ========== */}
-
- Secret -
- + 시크릿키 +
+ setKisAppSecretInput(e.target.value)} - onFocus={() => setFocusedField("appSecret")} - onBlur={() => setFocusedField(null)} - placeholder="App Secret 입력" - className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600" - autoComplete="off" - /> + onFocus={() => setFocusedField("appSecret")} + onBlur={() => setFocusedField(null)} + placeholder="한국투자증권 앱시크릿키 입력" + 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" + /> @@ -305,13 +265,13 @@ export function KisAuthForm() { 검증 중 - ) : ( - - - API 키 연결 - - )} - + ) : ( + + + 앱키 연결 확인 + + )} + {isKisVerified && ( + + +
+ {errorMessage && ( +

+ + {errorMessage} +

+ )} + {statusMessage && ( +

+ + {statusMessage} +

+ )} + {!statusMessage && !errorMessage && !isKisVerified && ( +

+ 먼저 앱키 연결을 완료해 주세요. +

+ )} + {!statusMessage && !errorMessage && isKisProfileVerified && ( +

+ 확인된 계좌: {maskAccountNo(verifiedAccountNo)} +

+ )} +
+ + + ); +} + +/** + * @description KIS 계좌번호(8-2) 입력 포맷을 검증합니다. + * @param value 사용자 입력 계좌번호 + * @returns 형식 유효 여부 + * @see features/settings/components/KisProfileForm.tsx handleValidateProfile + */ +function isValidAccountNo(value: string) { + const digits = value.replace(/\D/g, ""); + return digits.length === 10; +} + +/** + * @description 표시용 계좌번호를 마스킹 처리합니다. + * @param value 계좌번호(8-2) + * @returns 마스킹 계좌번호 + * @see features/settings/components/KisProfileForm.tsx 확인된 값 표시 + */ +function maskAccountNo(value: string | null) { + if (!value) return "-"; + const digits = value.replace(/\D/g, ""); + if (digits.length !== 10) return "********"; + return "********-**"; +} diff --git a/features/settings/components/SettingsContainer.tsx b/features/settings/components/SettingsContainer.tsx index 338713c..f6b31a8 100644 --- a/features/settings/components/SettingsContainer.tsx +++ b/features/settings/components/SettingsContainer.tsx @@ -1,7 +1,9 @@ "use client"; +import { Info } from "lucide-react"; import { useShallow } from "zustand/react/shallow"; import { KisAuthForm } from "@/features/settings/components/KisAuthForm"; +import { KisProfileForm } from "@/features/settings/components/KisProfileForm"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; /** @@ -11,10 +13,17 @@ import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-st */ export function SettingsContainer() { // 상태 정의: 연결 상태 표시용 전역 인증 상태를 구독합니다. - const { verifiedCredentials, isKisVerified } = useKisRuntimeStore( + const { + verifiedCredentials, + isKisVerified, + isKisProfileVerified, + verifiedAccountNo, + } = useKisRuntimeStore( useShallow((state) => ({ verifiedCredentials: state.verifiedCredentials, isKisVerified: state.isKisVerified, + isKisProfileVerified: state.isKisProfileVerified, + verifiedAccountNo: state.verifiedAccountNo, })), ); @@ -23,7 +32,7 @@ export function SettingsContainer() { {/* ========== STATUS CARD ========== */}

- KIS API 설정 + 한국투자증권 연결 설정

연결 상태: @@ -39,13 +48,51 @@ export function SettingsContainer() { )}
+
+ 계좌 인증 상태: + {isKisProfileVerified ? ( + + 확인됨 ({maskAccountNo(verifiedAccountNo)}) + + ) : ( + + 미확인 + + )} +
- {/* ========== AUTH FORM CARD ========== */} -
- + {/* ========== PRIVACY NOTICE ========== */} +
+

+ + 입력 정보 보관 안내 +

+

+ 이 화면에서 입력한 한국투자증권 앱키, 앱시크릿키, 계좌번호는 서버 DB에 저장하지 않습니다. + 현재 사용 중인 브라우저(클라이언트)에서만 관리되며, 연결 해제 시 즉시 지울 수 있습니다. +

+ + {/* ========== FORM GRID ========== */} +
+ + +
); } +/** + * @description 계좌번호 마스킹 문자열을 반환합니다. + * @param value 계좌번호(8-2) + * @returns 마스킹 계좌번호 + * @see features/settings/components/SettingsContainer.tsx 프로필 상태 라벨 표시 + */ +function maskAccountNo(value: string | null) { + if (!value) return "-"; + const digits = value.replace(/\D/g, ""); + if (digits.length !== 10) return "********"; + return "********-**"; +} + diff --git a/features/settings/store/use-kis-runtime-store.ts b/features/settings/store/use-kis-runtime-store.ts index 043f105..b0aa8e3 100644 --- a/features/settings/store/use-kis-runtime-store.ts +++ b/features/settings/store/use-kis-runtime-store.ts @@ -30,6 +30,8 @@ interface KisRuntimeStoreState { verifiedCredentials: KisRuntimeCredentials | null; isKisVerified: boolean; + isKisProfileVerified: boolean; + verifiedAccountNo: string | null; tradingEnv: KisTradingEnv; wsApprovalKey: string | null; @@ -47,6 +49,10 @@ interface KisRuntimeStoreActions { credentials: KisRuntimeCredentials, tradingEnv: KisTradingEnv, ) => void; + setVerifiedKisProfile: (profile: { + accountNo: string; + }) => void; + invalidateKisProfileVerification: () => void; invalidateKisVerification: () => void; clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void; getOrFetchWsConnection: () => Promise; @@ -60,15 +66,23 @@ const INITIAL_STATE: KisRuntimeStoreState = { kisAccountNoInput: "", verifiedCredentials: null, isKisVerified: false, + isKisProfileVerified: false, + verifiedAccountNo: null, tradingEnv: "real", wsApprovalKey: null, wsUrl: null, _hasHydrated: false, }; +const RESET_PROFILE_STATE = { + isKisProfileVerified: false, + verifiedAccountNo: null, +} as const; + const RESET_VERIFICATION_STATE = { verifiedCredentials: null, isKisVerified: false, + ...RESET_PROFILE_STATE, wsApprovalKey: null, wsUrl: null, }; @@ -105,10 +119,16 @@ export const useKisRuntimeStore = create< }), setKisAccountNoInput: (accountNo) => - set({ + set((state) => ({ kisAccountNoInput: accountNo, - ...RESET_VERIFICATION_STATE, - }), + ...RESET_PROFILE_STATE, + verifiedCredentials: state.verifiedCredentials + ? { + ...state.verifiedCredentials, + accountNo: "", + } + : null, + })), setVerifiedKisSession: (credentials, tradingEnv) => set({ @@ -119,6 +139,33 @@ export const useKisRuntimeStore = create< wsUrl: null, }), + setVerifiedKisProfile: ({ accountNo }) => + set((state) => ({ + isKisProfileVerified: true, + verifiedAccountNo: accountNo, + verifiedCredentials: state.verifiedCredentials + ? { + ...state.verifiedCredentials, + accountNo, + } + : state.verifiedCredentials, + wsApprovalKey: null, + wsUrl: null, + })), + + invalidateKisProfileVerification: () => + set((state) => ({ + ...RESET_PROFILE_STATE, + verifiedCredentials: state.verifiedCredentials + ? { + ...state.verifiedCredentials, + accountNo: "", + } + : state.verifiedCredentials, + wsApprovalKey: null, + wsUrl: null, + })), + invalidateKisVerification: () => set({ ...RESET_VERIFICATION_STATE, @@ -196,6 +243,8 @@ export const useKisRuntimeStore = create< kisAccountNoInput: state.kisAccountNoInput, verifiedCredentials: state.verifiedCredentials, isKisVerified: state.isKisVerified, + isKisProfileVerified: state.isKisProfileVerified, + verifiedAccountNo: state.verifiedAccountNo, tradingEnv: state.tradingEnv, // wsApprovalKey/wsUrl are kept in memory only (expiration-sensitive). }), diff --git a/features/trade/components/guards/TradeAccessGate.tsx b/features/trade/components/guards/TradeAccessGate.tsx index d5ff2ce..15d75e5 100644 --- a/features/trade/components/guards/TradeAccessGate.tsx +++ b/features/trade/components/guards/TradeAccessGate.tsx @@ -18,10 +18,10 @@ export function TradeAccessGate({ canTrade }: TradeAccessGateProps) {
{/* ========== UNVERIFIED NOTICE ========== */}

- 트레이딩을 시작하려면 KIS API 인증이 필요합니다. + 트레이딩을 시작하려면 한국투자증권 연결이 필요합니다.

- 설정 페이지에서 App Key/App Secret을 입력하고 연결 상태를 확인해 주세요. + 설정 페이지에서 앱키, 앱시크릿키, 계좌번호를 입력하고 연결을 완료해 주세요.

{/* ========== ACTION ========== */} diff --git a/features/trade/types/trade.types.ts b/features/trade/types/trade.types.ts index eeed3bc..84fe025 100644 --- a/features/trade/types/trade.types.ts +++ b/features/trade/types/trade.types.ts @@ -220,3 +220,15 @@ export interface DashboardKisWsApprovalResponse { approvalKey?: string; wsUrl?: string; } + +/** + * KIS 계좌 검증 API 응답 + */ +export interface DashboardKisProfileValidateResponse { + ok: boolean; + tradingEnv: KisTradingEnv; + message: string; + account: { + normalizedAccountNo: string; + }; +} diff --git a/lib/kis/dashboard.ts b/lib/kis/dashboard.ts index 86bdcb4..05ba248 100644 --- a/lib/kis/dashboard.ts +++ b/lib/kis/dashboard.ts @@ -29,6 +29,17 @@ interface KisBalanceOutput2Row { asst_icdc_erng_rt?: string; } +interface KisAccountBalanceOutput2Row { + tot_asst_amt?: string; + nass_tot_amt?: string; + tot_dncl_amt?: string; + dncl_amt?: string; + loan_amt_smtl?: string; + pchs_amt_smtl?: string; + evlu_amt_smtl?: string; + evlu_pfls_amt_smtl?: string; +} + interface KisIndexOutputRow { bstp_nmix_prpr?: string; bstp_nmix_prdy_vrss?: string; @@ -36,11 +47,57 @@ interface KisIndexOutputRow { prdy_vrss_sign?: string; } +interface KisDailyCcldOutput1Row { + ord_dt?: string; + ord_tmd?: string; + odno?: string; + ord_dvsn_name?: string; + sll_buy_dvsn_cd?: string; + sll_buy_dvsn_cd_name?: string; + pdno?: string; + prdt_name?: string; + ord_qty?: string; + ord_unpr?: string; + tot_ccld_qty?: string; + tot_ccld_amt?: string; + avg_prvs?: string; + rmn_qty?: string; + cncl_yn?: string; +} + +interface KisPeriodTradeProfitOutput1Row { + trad_dt?: string; + pdno?: string; + prdt_name?: string; + trad_dvsn_name?: string; + buy_qty?: string; + buy_amt?: string; + sll_qty?: string; + sll_amt?: string; + rlzt_pfls?: string; + pfls_rt?: string; + fee?: string; + tl_tax?: string; +} + +interface KisPeriodTradeProfitOutput2Row { + tot_rlzt_pfls?: string; + tot_pftrt?: string; + buy_tr_amt_smtl?: string; + sll_tr_amt_smtl?: string; + tot_fee?: string; + tot_tltx?: string; +} + export interface DomesticBalanceSummary { totalAmount: number; cashBalance: number; totalProfitLoss: number; totalProfitRate: number; + netAssetAmount: number; + evaluationAmount: number; + purchaseAmount: number; + loanAmount: number; } export interface DomesticHoldingItem { @@ -69,6 +126,54 @@ export interface DomesticMarketIndexResult { changeRate: number; } +export interface DomesticOrderHistoryItem { + orderDate: string; + orderTime: string; + orderNo: string; + symbol: string; + name: string; + side: "buy" | "sell" | "unknown"; + orderTypeName: string; + orderPrice: number; + orderQuantity: number; + filledQuantity: number; + filledAmount: number; + averageFilledPrice: number; + remainingQuantity: number; + isCanceled: boolean; +} + +export interface DomesticTradeJournalItem { + tradeDate: string; + symbol: string; + name: string; + side: "buy" | "sell" | "unknown"; + buyQuantity: number; + buyAmount: number; + sellQuantity: number; + sellAmount: number; + realizedProfit: number; + realizedRate: number; + fee: number; + tax: number; +} + +export interface DomesticTradeJournalSummary { + totalRealizedProfit: number; + totalRealizedRate: number; + totalBuyAmount: number; + totalSellAmount: number; + totalFee: number; + totalTax: number; +} + +export interface DomesticDashboardActivityResult { + orders: DomesticOrderHistoryItem[]; + tradeJournal: DomesticTradeJournalItem[]; + journalSummary: DomesticTradeJournalSummary; + warnings: string[]; +} + const MARKET_BY_SYMBOL = new Map( KOREAN_STOCK_INDEX.map((item) => [item.symbol, item.market] as const), ); @@ -82,6 +187,21 @@ const INDEX_TARGETS: Array<{ { market: "KOSDAQ", code: "1001", name: "코스닥" }, ]; +const DASHBOARD_ORDER_LOOKBACK_DAYS = 30; +const DASHBOARD_JOURNAL_LOOKBACK_DAYS = 90; + +interface DashboardBalanceInquirePreset { + inqrDvsn: "01" | "02"; + prcsDvsn: "00" | "01"; +} + +const DASHBOARD_BALANCE_INQUIRE_PRESETS: DashboardBalanceInquirePreset[] = [ + // 공식 문서(주식잔고조회[v1_국내주식-006].xlsx) 기본값 + { inqrDvsn: "01", prcsDvsn: "01" }, + // 일부 환경 호환값 + { inqrDvsn: "02", prcsDvsn: "00" }, +]; + /** * KIS 잔고조회 API를 호출해 대시보드 모델로 변환합니다. * @param account KIS 계좌번호(8-2) 파트 @@ -93,32 +213,22 @@ export async function getDomesticDashboardBalance( account: KisAccountParts, credentials?: KisCredentialInput, ): Promise { - const trId = - normalizeTradingEnv(credentials?.tradingEnv) === "real" - ? "TTTC8434R" - : "VTTC8434R"; + const tradingEnv = normalizeTradingEnv(credentials?.tradingEnv); + const inquireBalanceTrId = tradingEnv === "real" ? "TTTC8434R" : "VTTC8434R"; + const inquireAccountBalanceTrId = + tradingEnv === "real" ? "CTRP6548R" : "VTRP6548R"; - 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 [balanceResponse, accountBalanceResponse] = await Promise.all([ + getDomesticInquireBalanceEnvelope(account, inquireBalanceTrId, credentials), + getDomesticAccountBalanceSummaryRow( + account, + inquireAccountBalanceTrId, + credentials, + ), + ]); - const holdingRows = parseRows(response.output1); - const summaryRow = parseFirstRow(response.output2); + const holdingRows = parseRows(balanceResponse.output1); + const summaryRow = parseFirstRow(balanceResponse.output2); const holdings = holdingRows .map((row) => { @@ -139,19 +249,36 @@ export async function getDomesticDashboardBalance( }) .filter((item): item is DomesticHoldingItem => Boolean(item)); - const cashBalance = toNumber(summaryRow?.dnca_tot_amt); + const cashBalance = firstPositiveNumber( + toNumber(accountBalanceResponse?.tot_dncl_amt), + toNumber(accountBalanceResponse?.dncl_amt), + toNumber(summaryRow?.dnca_tot_amt), + ); const holdingsEvalAmount = sumNumbers(holdings.map((item) => item.evaluationAmount)); - const stockEvalAmount = firstPositiveNumber( + const evaluationAmount = firstPositiveNumber( + toNumber(accountBalanceResponse?.evlu_amt_smtl), toNumber(summaryRow?.scts_evlu_amt), toNumber(summaryRow?.evlu_amt_smtl_amt), holdingsEvalAmount, ); + const purchaseAmount = firstPositiveNumber( + toNumber(accountBalanceResponse?.pchs_amt_smtl), + Math.max(evaluationAmount - sumNumbers(holdings.map((item) => item.profitLoss)), 0), + ); + const loanAmount = firstPositiveNumber(toNumber(accountBalanceResponse?.loan_amt_smtl)); + const netAssetAmount = firstPositiveNumber( + toNumber(accountBalanceResponse?.nass_tot_amt), + evaluationAmount + cashBalance - loanAmount, + ); const totalAmount = firstPositiveNumber( - stockEvalAmount + cashBalance, + toNumber(accountBalanceResponse?.tot_asst_amt), + netAssetAmount + loanAmount, + evaluationAmount + cashBalance, toNumber(summaryRow?.tot_evlu_amt), holdingsEvalAmount + cashBalance, ); const totalProfitLoss = firstDefinedNumber( + toOptionalNumber(accountBalanceResponse?.evlu_pfls_amt_smtl), toOptionalNumber(summaryRow?.evlu_pfls_smtl_amt), sumNumbers(holdings.map((item) => item.profitLoss)), ); @@ -166,11 +293,95 @@ export async function getDomesticDashboardBalance( cashBalance, totalProfitLoss, totalProfitRate, + netAssetAmount, + evaluationAmount, + purchaseAmount, + loanAmount, }, holdings, }; } +/** + * 주식잔고조회(v1_국내주식-006)를 문서 기본값 우선으로 호출합니다. + * @param account KIS 계좌번호(8-2) 파트 + * @param trId 거래 환경별 TR ID + * @param credentials 사용자 입력 키(선택) + * @returns KIS 잔고 조회 원본 응답 + * @see lib/kis/dashboard.ts getDomesticDashboardBalance + */ +async function getDomesticInquireBalanceEnvelope( + account: KisAccountParts, + trId: string, + credentials?: KisCredentialInput, +) { + const errors: string[] = []; + + for (const preset of DASHBOARD_BALANCE_INQUIRE_PRESETS) { + try { + return 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: preset.inqrDvsn, + UNPR_DVSN: "01", + FUND_STTL_ICLD_YN: "N", + FNCG_AMT_AUTO_RDPT_YN: "N", + PRCS_DVSN: preset.prcsDvsn, + CTX_AREA_FK100: "", + CTX_AREA_NK100: "", + }, + credentials, + ); + } catch (error) { + errors.push( + `INQR_DVSN=${preset.inqrDvsn}, PRCS_DVSN=${preset.prcsDvsn}: ${error instanceof Error ? error.message : "호출 실패"}`, + ); + } + } + + throw new Error( + `주식잔고조회(v1_국내주식-006) 호출 실패: ${errors.join(" | ")}`, + ); +} + +/** + * 투자계좌자산현황조회(v1_국내주식-048)의 output2를 조회합니다. + * @param account KIS 계좌번호(8-2) 파트 + * @param trId 거래 환경별 TR ID (실전: CTRP6548R, 모의: VTRP6548R) + * @param credentials 사용자 입력 키(선택) + * @returns 계좌 자산 요약(output2) 또는 null + * @see C:/dev/auto-trade/.tmp/open-trading-api/examples_llm/domestic_stock/inquire_account_balance/inquire_account_balance.py + */ +async function getDomesticAccountBalanceSummaryRow( + account: KisAccountParts, + trId: string, + credentials?: KisCredentialInput, +) { + try { + const response = await kisGet( + "/uapi/domestic-stock/v1/trading/inquire-account-balance", + trId, + { + CANO: account.accountNo, + ACNT_PRDT_CD: account.accountProductCode, + INQR_DVSN_1: "", + BSPR_BF_DT_APLY_YN: "", + }, + credentials, + ); + + return parseFirstRow(response.output2); + } catch { + // 일부 계좌/환경에서는 v1_국내주식-048 조회가 제한될 수 있어, 잔고 API 값으로 폴백합니다. + return null; + } +} + /** * KOSPI/KOSDAQ 지수를 조회해 대시보드 모델로 변환합니다. * @param credentials 사용자 입력 키(선택) @@ -210,6 +421,399 @@ export async function getDomesticDashboardIndices( return results; } +/** + * 대시보드 하단의 주문내역/매매일지 데이터를 조회합니다. + * @param account KIS 계좌번호(8-2) 파트 + * @param credentials 사용자 입력 키(선택) + * @returns 주문내역/매매일지/요약/경고 목록 + * @remarks UI 흐름: 대시보드 하단 진입 -> activity API 호출 -> 주문내역/매매일지 탭 렌더링 + * @see app/api/kis/domestic/activity/route.ts 대시보드 활동 데이터 API 응답 생성 + * @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 UI 렌더링 + */ +export async function getDomesticDashboardActivity( + account: KisAccountParts, + credentials?: KisCredentialInput, +): Promise { + const [orderResult, journalResult] = await Promise.allSettled([ + getDomesticOrderHistory(account, credentials), + getDomesticTradeJournal(account, credentials), + ]); + + const warnings: string[] = []; + + const orders = + orderResult.status === "fulfilled" + ? orderResult.value + : []; + + if (orderResult.status === "rejected") { + warnings.push( + orderResult.reason instanceof Error + ? `주문내역 조회 실패: ${orderResult.reason.message}` + : "주문내역 조회에 실패했습니다.", + ); + } + + const tradeJournal = + journalResult.status === "fulfilled" + ? journalResult.value.items + : []; + const journalSummary = + journalResult.status === "fulfilled" + ? journalResult.value.summary + : createEmptyJournalSummary(); + + if (journalResult.status === "rejected") { + const tradingEnv = normalizeTradingEnv(credentials?.tradingEnv); + const defaultMessage = + tradingEnv === "mock" + ? "매매일지 API는 모의투자에서 지원되지 않거나 조회 제한이 있을 수 있습니다." + : "매매일지 조회에 실패했습니다."; + + warnings.push( + journalResult.reason instanceof Error + ? `매매일지 조회 실패: ${journalResult.reason.message}` + : defaultMessage, + ); + } + + if (orderResult.status === "rejected" && journalResult.status === "rejected") { + throw new Error("주문내역/매매일지를 모두 조회하지 못했습니다."); + } + + return { + orders, + tradeJournal, + journalSummary, + warnings, + }; +} + +/** + * 주식일별주문체결조회(v1_국내주식-005)로 최근 주문내역을 조회합니다. + * @param account KIS 계좌번호(8-2) 파트 + * @param credentials 사용자 입력 키(선택) + * @returns 주문내역 목록(최신순) + * @see C:/dev/auto-trade/.tmp/open-trading-api/examples_llm/domestic_stock/inquire_daily_ccld/inquire_daily_ccld.py + */ +async function getDomesticOrderHistory( + account: KisAccountParts, + credentials?: KisCredentialInput, +) { + const tradingEnv = normalizeTradingEnv(credentials?.tradingEnv); + const trId = tradingEnv === "real" ? "TTTC0081R" : "VTTC0081R"; + const range = getLookbackRangeYmd(DASHBOARD_ORDER_LOOKBACK_DAYS); + + const response = await kisGet( + "/uapi/domestic-stock/v1/trading/inquire-daily-ccld", + trId, + { + CANO: account.accountNo, + ACNT_PRDT_CD: account.accountProductCode, + INQR_STRT_DT: range.startDate, + INQR_END_DT: range.endDate, + SLL_BUY_DVSN_CD: "00", + PDNO: "", + CCLD_DVSN: "00", + INQR_DVSN: "00", + INQR_DVSN_3: "00", + ORD_GNO_BRNO: "", + ODNO: "", + INQR_DVSN_1: "", + CTX_AREA_FK100: "", + CTX_AREA_NK100: "", + EXCG_ID_DVSN_CD: "ALL", + }, + credentials, + ); + + const rows = parseRows(response.output1); + const mappedRows = rows.map((row) => { + const orderDateRaw = toDigits(row.ord_dt); + const orderTimeRaw = normalizeTimeDigits(row.ord_tmd); + const symbol = (row.pdno ?? "").trim(); + const name = (row.prdt_name ?? "").trim(); + const orderNo = (row.odno ?? "").trim(); + const side = parseTradeSide(row.sll_buy_dvsn_cd, row.sll_buy_dvsn_cd_name); + + return { + orderDate: formatDateLabel(orderDateRaw), + orderTime: formatTimeLabel(orderTimeRaw), + orderNo, + symbol, + name: name || symbol || "-", + side, + orderTypeName: (row.ord_dvsn_name ?? "").trim() || "일반", + orderPrice: toNumber(row.ord_unpr), + orderQuantity: toNumber(row.ord_qty), + filledQuantity: toNumber(row.tot_ccld_qty), + filledAmount: toNumber(row.tot_ccld_amt), + averageFilledPrice: toNumber(row.avg_prvs), + remainingQuantity: toNumber(row.rmn_qty), + isCanceled: (row.cncl_yn ?? "").trim().toUpperCase() === "Y", + sortKey: `${orderDateRaw}${orderTimeRaw}`, + }; + }); + + const normalized = mappedRows + .sort((a, b) => b.sortKey.localeCompare(a.sortKey)) + .slice(0, 100) + .map((item) => ({ + orderDate: item.orderDate, + orderTime: item.orderTime, + orderNo: item.orderNo, + symbol: item.symbol, + name: item.name, + side: item.side, + orderTypeName: item.orderTypeName, + orderPrice: item.orderPrice, + orderQuantity: item.orderQuantity, + filledQuantity: item.filledQuantity, + filledAmount: item.filledAmount, + averageFilledPrice: item.averageFilledPrice, + remainingQuantity: item.remainingQuantity, + isCanceled: item.isCanceled, + })); + + return normalized; +} + +/** + * 기간별매매손익현황조회(v1_국내주식-060)로 매매일지 데이터를 조회합니다. + * @param account KIS 계좌번호(8-2) 파트 + * @param credentials 사용자 입력 키(선택) + * @returns 매매일지 목록/요약 + * @see C:/dev/auto-trade/.tmp/open-trading-api/examples_llm/domestic_stock/inquire_period_trade_profit/inquire_period_trade_profit.py + * @see C:/dev/auto-trade/.tmp/open-trading-api/kis_apis.xlsx v1_국내주식-060 모의 TR 미표기 + */ +async function getDomesticTradeJournal( + account: KisAccountParts, + credentials?: KisCredentialInput, +) { + const tradingEnv = normalizeTradingEnv(credentials?.tradingEnv); + const range = getLookbackRangeYmd(DASHBOARD_JOURNAL_LOOKBACK_DAYS); + const trIdCandidates = + tradingEnv === "real" + ? ["TTTC8715R"] + : ["VTTC8715R", "TTTC8715R"]; + + let response: { output1?: unknown; output2?: unknown } | null = null; + let lastError: Error | null = null; + + for (const trId of trIdCandidates) { + try { + response = await kisGet( + "/uapi/domestic-stock/v1/trading/inquire-period-trade-profit", + trId, + { + CANO: account.accountNo, + ACNT_PRDT_CD: account.accountProductCode, + SORT_DVSN: "02", + INQR_STRT_DT: range.startDate, + INQR_END_DT: range.endDate, + CBLC_DVSN: "00", + PDNO: "", + CTX_AREA_FK100: "", + CTX_AREA_NK100: "", + }, + credentials, + ); + break; + } catch (error) { + lastError = error instanceof Error ? error : new Error("매매일지 조회 실패"); + } + } + + if (!response) { + throw lastError ?? new Error("매매일지 조회 실패"); + } + + const rows = parseRows(response.output1); + const summaryRow = parseFirstRow(response.output2); + + const items = rows + .map((row) => { + const tradeDateRaw = toDigits(row.trad_dt); + const symbol = (row.pdno ?? "").trim(); + const name = (row.prdt_name ?? "").trim(); + + return { + tradeDate: formatDateLabel(tradeDateRaw), + symbol, + name: name || symbol || "-", + side: parseTradeSide(undefined, row.trad_dvsn_name), + buyQuantity: toNumber(row.buy_qty), + buyAmount: toNumber(row.buy_amt), + sellQuantity: toNumber(row.sll_qty), + sellAmount: toNumber(row.sll_amt), + realizedProfit: toNumber(row.rlzt_pfls), + realizedRate: toNumber(row.pfls_rt), + fee: toNumber(row.fee), + tax: toNumber(row.tl_tax), + sortKey: tradeDateRaw, + } satisfies DomesticTradeJournalItem & { sortKey: string }; + }) + .sort((a, b) => b.sortKey.localeCompare(a.sortKey)) + .slice(0, 100) + .map((item) => ({ + tradeDate: item.tradeDate, + symbol: item.symbol, + name: item.name, + side: item.side, + buyQuantity: item.buyQuantity, + buyAmount: item.buyAmount, + sellQuantity: item.sellQuantity, + sellAmount: item.sellAmount, + realizedProfit: item.realizedProfit, + realizedRate: item.realizedRate, + fee: item.fee, + tax: item.tax, + })); + + const summary = { + totalRealizedProfit: firstDefinedNumber( + toOptionalNumber(summaryRow?.tot_rlzt_pfls), + sumNumbers(items.map((item) => item.realizedProfit)), + ), + totalRealizedRate: firstDefinedNumber( + toOptionalNumber(summaryRow?.tot_pftrt), + calcProfitRate( + sumNumbers(items.map((item) => item.realizedProfit)), + sumNumbers(items.map((item) => item.buyAmount)), + ), + ), + totalBuyAmount: firstDefinedNumber( + toOptionalNumber(summaryRow?.buy_tr_amt_smtl), + sumNumbers(items.map((item) => item.buyAmount)), + ), + totalSellAmount: firstDefinedNumber( + toOptionalNumber(summaryRow?.sll_tr_amt_smtl), + sumNumbers(items.map((item) => item.sellAmount)), + ), + totalFee: firstDefinedNumber( + toOptionalNumber(summaryRow?.tot_fee), + sumNumbers(items.map((item) => item.fee)), + ), + totalTax: firstDefinedNumber( + toOptionalNumber(summaryRow?.tot_tltx), + sumNumbers(items.map((item) => item.tax)), + ), + } satisfies DomesticTradeJournalSummary; + + return { + items, + summary, + }; +} + +/** + * 기준일 기준 N일 범위의 YYYYMMDD 기간을 반환합니다. + * @param lookbackDays 과거 조회 일수 + * @returns 시작/종료 일자 + * @see lib/kis/dashboard.ts getDomesticOrderHistory/getDomesticTradeJournal 조회 기간 계산 + */ +function getLookbackRangeYmd(lookbackDays: number) { + const end = new Date(); + const start = new Date(end); + start.setDate(end.getDate() - lookbackDays); + + return { + startDate: formatYmd(start), + endDate: formatYmd(end), + }; +} + +/** + * Date를 YYYYMMDD 문자열로 변환합니다. + * @param date 기준 일자 + * @returns YYYYMMDD + * @see lib/kis/dashboard.ts getLookbackRangeYmd + */ +function formatYmd(date: Date) { + const year = String(date.getFullYear()); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}${month}${day}`; +} + +/** + * 문자열에서 숫자만 추출합니다. + * @param value 원본 문자열 + * @returns 숫자 문자열 + * @see lib/kis/dashboard.ts 주문일자/매매일자/시각 정규화 + */ +function toDigits(value?: string) { + return (value ?? "").replace(/\D/g, ""); +} + +/** + * 주문 시각을 HHMMSS로 정규화합니다. + * @param value 시각 문자열 + * @returns 6자리 시각 문자열 + * @see lib/kis/dashboard.ts getDomesticOrderHistory 정렬/표시 시각 생성 + */ +function normalizeTimeDigits(value?: string) { + const digits = toDigits(value); + if (!digits) return "000000"; + return digits.padEnd(6, "0").slice(0, 6); +} + +/** + * YYYYMMDD를 YYYY-MM-DD로 변환합니다. + * @param value 날짜 문자열 + * @returns YYYY-MM-DD 또는 "-" + * @see lib/kis/dashboard.ts 주문내역/매매일지 날짜 컬럼 표시 + */ +function formatDateLabel(value: string) { + if (value.length !== 8) return "-"; + return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`; +} + +/** + * HHMMSS를 HH:MM:SS로 변환합니다. + * @param value 시각 문자열 + * @returns HH:MM:SS 또는 "-" + * @see lib/kis/dashboard.ts 주문내역 시각 컬럼 표시 + */ +function formatTimeLabel(value: string) { + if (value.length !== 6) return "-"; + return `${value.slice(0, 2)}:${value.slice(2, 4)}:${value.slice(4, 6)}`; +} + +/** + * KIS 매수/매도 코드를 공통 side 값으로 변환합니다. + * @param code 매수매도구분코드 + * @param name 매수매도구분명 또는 매매구분명 + * @returns buy/sell/unknown + * @see lib/kis/dashboard.ts getDomesticOrderHistory/getDomesticTradeJournal + */ +function parseTradeSide(code?: string, name?: string): "buy" | "sell" | "unknown" { + const normalizedCode = (code ?? "").trim(); + const normalizedName = (name ?? "").trim(); + + if (normalizedCode === "01") return "sell"; + if (normalizedCode === "02") return "buy"; + if (normalizedName.includes("매도")) return "sell"; + if (normalizedName.includes("매수")) return "buy"; + return "unknown"; +} + +/** + * 매매일지 요약 기본값을 반환합니다. + * @returns 0으로 채운 요약 객체 + * @see lib/kis/dashboard.ts getDomesticDashboardActivity 매매일지 실패 폴백 + */ +function createEmptyJournalSummary(): DomesticTradeJournalSummary { + return { + totalRealizedProfit: 0, + totalRealizedRate: 0, + totalBuyAmount: 0, + totalSellAmount: 0, + totalFee: 0, + totalTax: 0, + }; +} + /** * 문자열 숫자를 number로 변환합니다. * @param value KIS 숫자 문자열