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