/** * @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"; import { calcProfitRate, calcProfitRateByPurchase, createEmptyJournalSummary, firstDefinedNumber, formatDateLabel, formatTimeLabel, getLookbackRangeYmd, normalizeSignedValue, normalizeTimeDigits, parseFirstRow, parseIndexRow, parseRows, parseTradeSide, pickNonZeroNumber, pickPreferredAmount, resolveCashBalance, sumNumbers, toDigits, toNumber, toOptionalNumber, } from "@/lib/kis/dashboard-helpers"; interface KisBalanceOutput1Row { pdno?: string; prdt_name?: string; hldg_qty?: string; ord_psbl_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 KisFluctuationOutputRow { data_rank?: string; stck_shrn_iscd?: string; hts_kor_isnm?: string; stck_prpr?: string; prdy_vrss?: string; prdy_vrss_sign?: string; prdy_ctrt?: string; acml_vol?: string; acml_tr_pbmn?: string; } interface KisVolumeRankOutputRow { data_rank?: string; mksc_shrn_iscd?: string; stck_shrn_iscd?: string; hts_kor_isnm?: string; stck_prpr?: string; prdy_vrss?: string; prdy_vrss_sign?: string; prdy_ctrt?: string; acml_vol?: string; acml_tr_pbmn?: string; } interface KisNewsTitleOutputRow { cntt_usiq_srno?: string; data_dt?: string; data_tm?: string; hts_pbnt_titl_cntt?: string; dorg?: string; news_ofer_entp_code?: string; iscd1?: string; iscd2?: string; iscd3?: string; iscd4?: string; iscd5?: 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; sellableQuantity: 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 DomesticMarketRankItem { rank: number; symbol: string; name: string; market: "KOSPI" | "KOSDAQ"; price: number; change: number; changeRate: number; volume: number; tradingValue: number; } export interface DomesticNewsHeadlineItem { id: string; title: string; source: string; publishedAt: string; symbols: string[]; } export interface DomesticMarketPulse { gainersCount: number; losersCount: number; popularByVolumeCount: number; popularByValueCount: number; newsCount: number; } export interface DomesticDashboardMarketHubResult { gainers: DomesticMarketRankItem[]; losers: DomesticMarketRankItem[]; popularByVolume: DomesticMarketRankItem[]; popularByValue: DomesticMarketRankItem[]; news: DomesticNewsHeadlineItem[]; pulse: DomesticMarketPulse; warnings: string[]; } 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; const DASHBOARD_MARKET_HUB_LIMIT = 10; 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 sellableQuantity = Math.max(0, Math.floor(toNumber(row.ord_psbl_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, sellableQuantity, averagePrice, currentPrice, evaluationAmount, profitLoss, profitRate, purchaseAmountBase, } satisfies DomesticHoldingItem & { purchaseAmountBase: number }; }) .filter( (item): item is DomesticHoldingItem & { purchaseAmountBase: number } => { if (!item) return false; // [Step 3] 전량 매도 후 잔존(수량 0) 레코드는 대시보드 보유종목에서 제외합니다. return item.quantity > 0; }, ); const holdings = normalizedHoldings.map((item) => ({ symbol: item.symbol, name: item.name, market: item.market, quantity: item.quantity, sellableQuantity: item.sellableQuantity, 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 credentials 사용자 입력 키(선택) * @returns 시장 허브 목록/요약/경고 * @remarks UI 흐름: /dashboard 시장 탭 진입 -> market-hub API -> 카드별 리스트 렌더링 * @see app/api/kis/domestic/market-hub/route.ts 시장 허브 API 응답 생성 */ export async function getDomesticDashboardMarketHub( credentials?: KisCredentialInput, ): Promise { const [ gainersResult, losersResult, popularVolumeResult, popularValueResult, newsResult, ] = await Promise.allSettled([ getDomesticTopGainers(credentials), getDomesticTopLosers(credentials), getDomesticVolumeRank(credentials, "0"), getDomesticVolumeRank(credentials, "3"), getDomesticNewsHeadlines(credentials), ]); const warnings: string[] = []; const gainers = gainersResult.status === "fulfilled" ? gainersResult.value : []; if (gainersResult.status === "rejected") { warnings.push( gainersResult.reason instanceof Error ? `급등주식 조회 실패: ${gainersResult.reason.message}` : "급등주식 조회에 실패했습니다.", ); } const losers = losersResult.status === "fulfilled" ? losersResult.value : []; if (losersResult.status === "rejected") { warnings.push( losersResult.reason instanceof Error ? `급락주식 조회 실패: ${losersResult.reason.message}` : "급락주식 조회에 실패했습니다.", ); } const popularByVolume = popularVolumeResult.status === "fulfilled" ? popularVolumeResult.value : []; if (popularVolumeResult.status === "rejected") { warnings.push( popularVolumeResult.reason instanceof Error ? `인기종목(거래량) 조회 실패: ${popularVolumeResult.reason.message}` : "인기종목(거래량) 조회에 실패했습니다.", ); } const popularByValue = popularValueResult.status === "fulfilled" ? popularValueResult.value : []; if (popularValueResult.status === "rejected") { warnings.push( popularValueResult.reason instanceof Error ? `거래대금 상위 조회 실패: ${popularValueResult.reason.message}` : "거래대금 상위 조회에 실패했습니다.", ); } const news = newsResult.status === "fulfilled" ? newsResult.value : []; if (newsResult.status === "rejected") { warnings.push( newsResult.reason instanceof Error ? `주요 뉴스 조회 실패: ${newsResult.reason.message}` : "주요 뉴스 조회에 실패했습니다.", ); } if ( gainersResult.status === "rejected" && losersResult.status === "rejected" && popularVolumeResult.status === "rejected" && popularValueResult.status === "rejected" && newsResult.status === "rejected" ) { throw new Error("시장 허브 데이터를 모두 조회하지 못했습니다."); } return { gainers, losers, popularByVolume, popularByValue, news, pulse: { gainersCount: gainers.length, losersCount: losers.length, popularByVolumeCount: popularByVolume.length, popularByValueCount: popularByValue.length, newsCount: news.length, }, warnings, }; } async function getDomesticTopGainers(credentials?: KisCredentialInput) { const fluctuationRows = await getDomesticFluctuationRows(credentials); const fluctuationMapped = fluctuationRows .map((row, index) => normalizeMarketRankItemFromFluctuation(row, index)) .filter((item): item is DomesticMarketRankItem => Boolean(item)) .filter((item) => item.changeRate > 0) .sort((a, b) => b.changeRate - a.changeRate); if (fluctuationMapped.length >= DASHBOARD_MARKET_HUB_LIMIT) { return fluctuationMapped.slice(0, DASHBOARD_MARKET_HUB_LIMIT); } // 변동률 순위 API가 빈 응답일 때 거래량 상위 중 상승률 상위로 폴백합니다. const fallbackRows = await getDomesticVolumeRank(credentials, "0"); const fallbackGainers = fallbackRows .filter((item) => item.changeRate > 0) .sort((a, b) => b.changeRate - a.changeRate); return dedupeMarketRankItems([...fluctuationMapped, ...fallbackGainers]).slice( 0, DASHBOARD_MARKET_HUB_LIMIT, ); } async function getDomesticTopLosers(credentials?: KisCredentialInput) { const fluctuationRows = await getDomesticFluctuationRows(credentials); const fluctuationMapped = fluctuationRows .map((row, index) => normalizeMarketRankItemFromFluctuation(row, index)) .filter((item): item is DomesticMarketRankItem => Boolean(item)) .filter((item) => item.changeRate < 0) .sort((a, b) => a.changeRate - b.changeRate); if (fluctuationMapped.length >= DASHBOARD_MARKET_HUB_LIMIT) { return fluctuationMapped.slice(0, DASHBOARD_MARKET_HUB_LIMIT); } const fallbackRows = await getDomesticVolumeRank(credentials, "0"); const fallbackLosers = fallbackRows .filter((item) => item.changeRate < 0) .sort((a, b) => a.changeRate - b.changeRate); return dedupeMarketRankItems([...fluctuationMapped, ...fallbackLosers]).slice( 0, DASHBOARD_MARKET_HUB_LIMIT, ); } async function getDomesticVolumeRank( credentials: KisCredentialInput | undefined, sortClassCode: "0" | "3", ) { const response = await kisGet( "/uapi/domestic-stock/v1/quotations/volume-rank", "FHPST01710000", { FID_COND_MRKT_DIV_CODE: "J", FID_COND_SCR_DIV_CODE: "20171", FID_INPUT_ISCD: "0000", FID_DIV_CLS_CODE: "0", FID_BLNG_CLS_CODE: sortClassCode, FID_TRGT_CLS_CODE: "111111111", FID_TRGT_EXLS_CLS_CODE: "0000000000", FID_INPUT_PRICE_1: "", FID_INPUT_PRICE_2: "", FID_VOL_CNT: "", FID_INPUT_DATE_1: "", }, credentials, ); const rows = parseRows(response.output); return rows .map((row, index) => normalizeMarketRankItemFromVolume(row, index)) .filter((item): item is DomesticMarketRankItem => Boolean(item)) .slice(0, DASHBOARD_MARKET_HUB_LIMIT); } async function getDomesticNewsHeadlines(credentials?: KisCredentialInput) { const response = await kisGet( "/uapi/domestic-stock/v1/quotations/news-title", "FHKST01011800", { FID_NEWS_OFER_ENTP_CODE: "", FID_COND_MRKT_CLS_CODE: "", FID_INPUT_ISCD: "", FID_TITL_CNTT: "", FID_INPUT_DATE_1: "", FID_INPUT_HOUR_1: "", FID_RANK_SORT_CLS_CODE: "", FID_INPUT_SRNO: "", }, credentials, ); const rows = parseRows(response.output); return rows .map((row, index) => normalizeNewsHeadlineItem(row, index)) .filter((item): item is DomesticNewsHeadlineItem => Boolean(item)) .slice(0, DASHBOARD_MARKET_HUB_LIMIT); } async function getDomesticFluctuationRows(credentials?: KisCredentialInput) { const presets = [ { fid_rank_sort_cls_code: "0000", fid_trgt_cls_code: "0", fid_trgt_exls_cls_code: "0", fid_rsfl_rate1: "", fid_rsfl_rate2: "", }, { fid_rank_sort_cls_code: "0", fid_trgt_cls_code: "111111111", fid_trgt_exls_cls_code: "0000000000", fid_rsfl_rate1: "", fid_rsfl_rate2: "", }, ] as const; const errors: string[] = []; for (const preset of presets) { try { const response = await kisGet( "/uapi/domestic-stock/v1/ranking/fluctuation", "FHPST01700000", { fid_cond_mrkt_div_code: "J", fid_cond_scr_div_code: "20170", fid_input_iscd: "0000", fid_rank_sort_cls_code: preset.fid_rank_sort_cls_code, fid_input_cnt_1: "0", fid_prc_cls_code: "0", fid_input_price_1: "", fid_input_price_2: "", fid_vol_cnt: "", fid_trgt_cls_code: preset.fid_trgt_cls_code, fid_trgt_exls_cls_code: preset.fid_trgt_exls_cls_code, fid_div_cls_code: "0", fid_rsfl_rate1: preset.fid_rsfl_rate1, fid_rsfl_rate2: preset.fid_rsfl_rate2, }, credentials, ); const rows = parseRows(response.output); if (rows.length > 0) { return rows; } } catch (error) { errors.push(error instanceof Error ? error.message : "호출 실패"); } } if (errors.length > 0) { throw new Error(errors.join(" | ")); } return [] as KisFluctuationOutputRow[]; } function normalizeMarketRankItemFromFluctuation( row: KisFluctuationOutputRow, index: number, ) { const symbol = (row.stck_shrn_iscd ?? "").trim(); if (!/^\d{6}$/.test(symbol)) return null; const signedChange = normalizeSignedValue( toNumber(row.prdy_vrss), row.prdy_vrss_sign, ); const signedChangeRate = normalizeSignedValue( toNumber(row.prdy_ctrt), row.prdy_vrss_sign, ); return { rank: resolveRank(row.data_rank, index), symbol, name: (row.hts_kor_isnm ?? "").trim() || symbol, market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI", price: toNumber(row.stck_prpr), change: signedChange, changeRate: signedChangeRate, volume: toNumber(row.acml_vol), tradingValue: toNumber(row.acml_tr_pbmn), } satisfies DomesticMarketRankItem; } function normalizeMarketRankItemFromVolume( row: KisVolumeRankOutputRow, index: number, ) { const symbol = (row.mksc_shrn_iscd ?? row.stck_shrn_iscd ?? "").trim(); if (!/^\d{6}$/.test(symbol)) return null; const signedChange = normalizeSignedValue( toNumber(row.prdy_vrss), row.prdy_vrss_sign, ); const signedChangeRate = normalizeSignedValue( toNumber(row.prdy_ctrt), row.prdy_vrss_sign, ); return { rank: resolveRank(row.data_rank, index), symbol, name: (row.hts_kor_isnm ?? "").trim() || symbol, market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI", price: toNumber(row.stck_prpr), change: signedChange, changeRate: signedChangeRate, volume: toNumber(row.acml_vol), tradingValue: toNumber(row.acml_tr_pbmn), } satisfies DomesticMarketRankItem; } function normalizeNewsHeadlineItem(row: KisNewsTitleOutputRow, index: number) { const title = (row.hts_pbnt_titl_cntt ?? "").trim(); if (!title) return null; const id = (row.cntt_usiq_srno ?? "").trim(); const symbols = [row.iscd1, row.iscd2, row.iscd3, row.iscd4, row.iscd5] .map((value) => (value ?? "").trim()) .filter((value, position, all) => /^\d{6}$/.test(value) && all.indexOf(value) === position); return { id: id || `news-${index + 1}`, title, source: (row.dorg ?? row.news_ofer_entp_code ?? "").trim() || "KIS", publishedAt: formatNewsTimestamp(row.data_dt, row.data_tm), symbols, } satisfies DomesticNewsHeadlineItem; } function dedupeMarketRankItems(items: DomesticMarketRankItem[]) { const seen = new Set(); return items.filter((item) => { if (seen.has(item.symbol)) return false; seen.add(item.symbol); return true; }); } function resolveRank(rawRank: string | undefined, fallbackIndex: number) { const parsed = Math.floor(toNumber(rawRank)); if (parsed > 0) return parsed; return fallbackIndex + 1; } function formatNewsTimestamp(rawDate?: string, rawTime?: string) { const dateDigits = toDigits(rawDate); if (dateDigits.length !== 8) return "-"; const timeDigits = normalizeTimeDigits(rawTime); return `${formatDateLabel(dateDigits)} ${formatTimeLabel(timeDigits)}`; } /** * 대시보드 하단의 주문내역/매매일지 데이터를 조회합니다. * @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, }; }