/** * @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; pchs_amt?: 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; nass_amt?: string; pchs_amt_smtl_amt?: string; evlu_amt_smtl_amt?: string; evlu_pfls_smtl_amt?: string; 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; bstp_nmix_prdy_ctrt?: string; 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; totalDepositAmount: number; totalProfitLoss: number; totalProfitRate: number; netAssetAmount: number; evaluationAmount: number; purchaseAmount: number; loanAmount: number; apiReportedTotalAmount: number; apiReportedNetAssetAmount: 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; } 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), ); const INDEX_TARGETS: Array<{ market: "KOSPI" | "KOSDAQ"; code: string; name: string; }> = [ { market: "KOSPI", code: "0001", name: "코스피" }, { 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) 파트 * @param credentials 사용자 입력 키(선택) * @returns 대시보드 요약/보유종목 모델 * @see app/api/kis/domestic/balance/route.ts 대시보드 잔고 API 응답 생성 */ export async function getDomesticDashboardBalance( account: KisAccountParts, credentials?: KisCredentialInput, ): Promise { const tradingEnv = normalizeTradingEnv(credentials?.tradingEnv); const inquireBalanceTrId = tradingEnv === "real" ? "TTTC8434R" : "VTTC8434R"; const inquireAccountBalanceTrId = tradingEnv === "real" ? "CTRP6548R" : "VTRP6548R"; const [balanceResponse, accountBalanceResponse] = await Promise.all([ getDomesticInquireBalanceEnvelope(account, inquireBalanceTrId, credentials), getDomesticAccountBalanceSummaryRow( account, inquireAccountBalanceTrId, credentials, ), ]); const holdingRows = parseRows(balanceResponse.output1); const summaryRow = parseFirstRow(balanceResponse.output2); const normalizedHoldings = holdingRows .map((row) => { const symbol = (row.pdno ?? "").trim(); if (!/^\d{6}$/.test(symbol)) return null; const quantity = toNumber(row.hldg_qty); const averagePrice = toNumber(row.pchs_avg_pric); const currentPrice = toNumber(row.prpr); const purchaseAmountBase = pickPreferredAmount( toOptionalNumber(row.pchs_amt), quantity * averagePrice, ); const evaluationAmount = pickPreferredAmount( toOptionalNumber(row.evlu_amt), quantity * (currentPrice > 0 ? currentPrice : averagePrice), ); const profitLoss = pickNonZeroNumber( toOptionalNumber(row.evlu_pfls_amt), evaluationAmount - purchaseAmountBase, ); const profitRate = pickNonZeroNumber( toOptionalNumber(row.evlu_pfls_rt), calcProfitRateByPurchase(profitLoss, purchaseAmountBase), ); return { symbol, name: (row.prdt_name ?? "").trim() || symbol, market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI", quantity, averagePrice, currentPrice, evaluationAmount, profitLoss, profitRate, purchaseAmountBase, } satisfies DomesticHoldingItem & { purchaseAmountBase: number }; }) .filter( (item): item is DomesticHoldingItem & { purchaseAmountBase: number } => Boolean(item), ); const holdings = normalizedHoldings.map((item) => ({ symbol: item.symbol, name: item.name, market: item.market, quantity: item.quantity, averagePrice: item.averagePrice, currentPrice: item.currentPrice, evaluationAmount: item.evaluationAmount, profitLoss: item.profitLoss, profitRate: item.profitRate, })); const holdingsEvalAmount = sumNumbers( normalizedHoldings.map((item) => item.evaluationAmount), ); const holdingsPurchaseAmount = sumNumbers( normalizedHoldings.map((item) => item.purchaseAmountBase), ); const evaluationAmount = pickPreferredAmount( toOptionalNumber(accountBalanceResponse?.evlu_amt_smtl), toOptionalNumber(summaryRow?.scts_evlu_amt), toOptionalNumber(summaryRow?.evlu_amt_smtl_amt), holdingsEvalAmount, ); const apiReportedTotalAmount = pickPreferredAmount( toOptionalNumber(accountBalanceResponse?.tot_asst_amt), toOptionalNumber(summaryRow?.tot_evlu_amt), ); const apiReportedNetAssetAmount = pickPreferredAmount( toOptionalNumber(accountBalanceResponse?.nass_tot_amt), toOptionalNumber(summaryRow?.nass_amt), ); const cashBalance = resolveCashBalance({ apiReportedTotalAmount, apiReportedNetAssetAmount, evaluationAmount, cashCandidates: [ toOptionalNumber(summaryRow?.dnca_tot_amt), toOptionalNumber(accountBalanceResponse?.dncl_amt), toOptionalNumber(accountBalanceResponse?.tot_dncl_amt), ], }); const totalDepositAmount = pickPreferredAmount( toOptionalNumber(summaryRow?.dnca_tot_amt), toOptionalNumber(accountBalanceResponse?.tot_dncl_amt), toOptionalNumber(accountBalanceResponse?.dncl_amt), cashBalance, ); const purchaseAmount = pickPreferredAmount( toOptionalNumber(accountBalanceResponse?.pchs_amt_smtl), toOptionalNumber(summaryRow?.pchs_amt_smtl_amt), holdingsPurchaseAmount, Math.max( evaluationAmount - sumNumbers(normalizedHoldings.map((item) => item.profitLoss)), 0, ), ); const loanAmount = pickPreferredAmount( toOptionalNumber(accountBalanceResponse?.loan_amt_smtl), ); // KIS 원본 총자산(대출/미수 포함 가능)과 순자산(실제 체감 자산)을 분리해 관리합니다. const grossTotalAmount = pickPreferredAmount( toOptionalNumber(summaryRow?.tot_evlu_amt), apiReportedTotalAmount, evaluationAmount + cashBalance, ); const netAssetAmount = pickPreferredAmount( apiReportedNetAssetAmount, grossTotalAmount - loanAmount, ); // 상단 "내 자산"은 순자산 기준(사용자 체감 자산)으로 표시합니다. const totalAmount = pickPreferredAmount( netAssetAmount, grossTotalAmount, ); const totalProfitLoss = pickNonZeroNumber( toOptionalNumber(accountBalanceResponse?.evlu_pfls_amt_smtl), toOptionalNumber(summaryRow?.evlu_pfls_smtl_amt), sumNumbers(holdings.map((item) => item.profitLoss)), ); const totalProfitRate = calcProfitRateByPurchase(totalProfitLoss, purchaseAmount); return { summary: { totalAmount, cashBalance, totalDepositAmount, totalProfitLoss, totalProfitRate, netAssetAmount, evaluationAmount, purchaseAmount, loanAmount, apiReportedTotalAmount, apiReportedNetAssetAmount, }, 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 사용자 입력 키(선택) * @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; } /** * 대시보드 하단의 주문내역/매매일지 데이터를 조회합니다. * @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 숫자 문자열 * @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; } /** * 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; } /** * 매입금액 대비 손익률을 계산합니다. * @param profit 손익 금액 * @param purchaseAmount 매입금액 * @returns 손익률(%) * @see lib/kis/dashboard.ts getDomesticDashboardBalance 현재 손익률 산출 */ function calcProfitRateByPurchase(profit: number, purchaseAmount: number) { if (purchaseAmount <= 0) return 0; return (profit / purchaseAmount) * 100; } /** * 총자산과 평가금액 기준으로 일관된 현금성 자산(예수금)을 계산합니다. * @param params 계산 파라미터 * @returns 현금성 자산 금액 * @remarks UI 흐름: 대시보드 API 응답 생성 -> 총자산/평가금/예수금 카드 반영 * @see lib/kis/dashboard.ts getDomesticDashboardBalance */ function resolveCashBalance(params: { apiReportedTotalAmount: number; apiReportedNetAssetAmount: number; evaluationAmount: number; cashCandidates: Array; }) { const { apiReportedTotalAmount, apiReportedNetAssetAmount, evaluationAmount, cashCandidates, } = params; const referenceTotalAmount = pickPreferredAmount( apiReportedNetAssetAmount, apiReportedTotalAmount, ); const candidateCash = pickPreferredAmount(...cashCandidates); const derivedCash = referenceTotalAmount > 0 ? Math.max(referenceTotalAmount - evaluationAmount, 0) : undefined; if (derivedCash === undefined) return candidateCash; // 후보 예수금 + 평가금이 기준 총자산(순자산 우선)과 크게 다르면 역산값을 사용합니다. const recomposedWithCandidate = candidateCash + evaluationAmount; const mismatchWithApi = Math.abs( recomposedWithCandidate - referenceTotalAmount, ); if (mismatchWithApi >= 1) { return derivedCash; } return candidateCash; } /** * 금액 후보 중 양수 값을 우선 선택합니다. * @param values 금액 후보 * @returns 양수 우선 금액 * @see lib/kis/dashboard.ts getDomesticDashboardBalance 금액 필드 폴백 계산 */ function pickPreferredAmount(...values: Array) { const positive = values.find( (value): value is number => value !== undefined && value > 0, ); if (positive !== undefined) return positive; return firstDefinedNumber(...values); } /** * 숫자 후보 중 0이 아닌 값을 우선 선택합니다. * @param values 숫자 후보 * @returns 0이 아닌 값 우선 결과 * @see lib/kis/dashboard.ts getDomesticDashboardBalance 손익/수익률 폴백 계산 */ function pickNonZeroNumber(...values: Array) { const nonZero = values.find( (value): value is number => value !== undefined && value !== 0, ); if (nonZero !== undefined) return nonZero; return firstDefinedNumber(...values); }