Files
auto-trade/lib/kis/dashboard.ts

941 lines
28 KiB
TypeScript

/**
* @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 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;
totalProfitLoss: number;
totalProfitRate: number;
netAssetAmount: number;
evaluationAmount: number;
purchaseAmount: number;
loanAmount: 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<DomesticBalanceResult> {
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<KisBalanceOutput1Row>(balanceResponse.output1);
const summaryRow = parseFirstRow<KisBalanceOutput2Row>(balanceResponse.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 = firstPositiveNumber(
toNumber(accountBalanceResponse?.tot_dncl_amt),
toNumber(accountBalanceResponse?.dncl_amt),
toNumber(summaryRow?.dnca_tot_amt),
);
const holdingsEvalAmount = sumNumbers(holdings.map((item) => item.evaluationAmount));
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(
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)),
);
const totalProfitRate = firstDefinedNumber(
toOptionalNumber(summaryRow?.asst_icdc_erng_rt),
calcProfitRate(totalProfitLoss, totalAmount),
);
return {
summary: {
totalAmount,
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<unknown>(
"/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<unknown>(
"/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<KisAccountBalanceOutput2Row>(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<DomesticMarketIndexResult[]> {
const results = await Promise.all(
INDEX_TARGETS.map(async (target) => {
const response = await kisGet<KisIndexOutputRow>(
"/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<DomesticDashboardActivityResult> {
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<unknown>(
"/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<KisDailyCcldOutput1Row>(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<unknown>(
"/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<KisPeriodTradeProfitOutput1Row>(response.output1);
const summaryRow = parseFirstRow<KisPeriodTradeProfitOutput2Row>(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<T>(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<T>(value: unknown) {
const rows = parseRows<T>(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<number | undefined>) {
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;
}