대시보드 실시간 기능 추가

This commit is contained in:
2026-02-13 12:17:35 +09:00
parent 12feeb2775
commit 1ac907cd27
35 changed files with 2790 additions and 1032 deletions

View File

@@ -14,6 +14,7 @@ interface KisBalanceOutput1Row {
prdt_name?: string;
hldg_qty?: string;
pchs_avg_pric?: string;
pchs_amt?: string;
prpr?: string;
evlu_amt?: string;
evlu_pfls_amt?: string;
@@ -24,6 +25,8 @@ 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;
@@ -92,12 +95,15 @@ interface KisPeriodTradeProfitOutput2Row {
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 {
@@ -230,73 +236,142 @@ export async function getDomesticDashboardBalance(
const holdingRows = parseRows<KisBalanceOutput1Row>(balanceResponse.output1);
const summaryRow = parseFirstRow<KisBalanceOutput2Row>(balanceResponse.output2);
const holdings = holdingRows
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: 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;
quantity,
averagePrice,
currentPrice,
evaluationAmount,
profitLoss,
profitRate,
purchaseAmountBase,
} satisfies DomesticHoldingItem & { purchaseAmountBase: number };
})
.filter((item): item is DomesticHoldingItem => Boolean(item));
.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 cashBalance = firstPositiveNumber(
toNumber(accountBalanceResponse?.tot_dncl_amt),
toNumber(accountBalanceResponse?.dncl_amt),
toNumber(summaryRow?.dnca_tot_amt),
const holdingsEvalAmount = sumNumbers(
normalizedHoldings.map((item) => item.evaluationAmount),
);
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),
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 purchaseAmount = firstPositiveNumber(
toNumber(accountBalanceResponse?.pchs_amt_smtl),
Math.max(evaluationAmount - sumNumbers(holdings.map((item) => item.profitLoss)), 0),
const apiReportedTotalAmount = pickPreferredAmount(
toOptionalNumber(accountBalanceResponse?.tot_asst_amt),
toOptionalNumber(summaryRow?.tot_evlu_amt),
);
const loanAmount = firstPositiveNumber(toNumber(accountBalanceResponse?.loan_amt_smtl));
const netAssetAmount = firstPositiveNumber(
toNumber(accountBalanceResponse?.nass_tot_amt),
evaluationAmount + cashBalance - loanAmount,
const apiReportedNetAssetAmount = pickPreferredAmount(
toOptionalNumber(accountBalanceResponse?.nass_tot_amt),
toOptionalNumber(summaryRow?.nass_amt),
);
const totalAmount = firstPositiveNumber(
toNumber(accountBalanceResponse?.tot_asst_amt),
netAssetAmount + loanAmount,
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,
toNumber(summaryRow?.tot_evlu_amt),
holdingsEvalAmount + cashBalance,
);
const totalProfitLoss = firstDefinedNumber(
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 = firstDefinedNumber(
toOptionalNumber(summaryRow?.asst_icdc_erng_rt),
calcProfitRate(totalProfitLoss, totalAmount),
);
const totalProfitRate = calcProfitRateByPurchase(totalProfitLoss, purchaseAmount);
return {
summary: {
totalAmount,
cashBalance,
totalDepositAmount,
totalProfitLoss,
totalProfitRate,
netAssetAmount,
evaluationAmount,
purchaseAmount,
loanAmount,
apiReportedTotalAmount,
apiReportedNetAssetAmount,
},
holdings,
};
@@ -895,16 +970,6 @@ function normalizeSignedValue(value: number, signCode?: string) {
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 후보 숫자 목록
@@ -938,3 +1003,86 @@ function calcProfitRate(profit: number, totalAmount: number) {
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<number | undefined>;
}) {
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<number | undefined>) {
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<number | undefined>) {
const nonZero = values.find(
(value): value is number => value !== undefined && value !== 0,
);
if (nonZero !== undefined) return nonZero;
return firstDefinedNumber(...values);
}