대시보드 실시간 기능 추가
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user