2026-02-12 14:20:07 +09:00
|
|
|
/**
|
|
|
|
|
* @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;
|
2026-02-13 12:17:35 +09:00
|
|
|
pchs_amt?: string;
|
2026-02-12 14:20:07 +09:00
|
|
|
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;
|
2026-02-13 12:17:35 +09:00
|
|
|
nass_amt?: string;
|
|
|
|
|
pchs_amt_smtl_amt?: string;
|
2026-02-12 14:20:07 +09:00
|
|
|
evlu_amt_smtl_amt?: string;
|
|
|
|
|
evlu_pfls_smtl_amt?: string;
|
|
|
|
|
asst_icdc_erng_rt?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 17:16:41 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 14:20:07 +09:00
|
|
|
interface KisIndexOutputRow {
|
|
|
|
|
bstp_nmix_prpr?: string;
|
|
|
|
|
bstp_nmix_prdy_vrss?: string;
|
|
|
|
|
bstp_nmix_prdy_ctrt?: string;
|
|
|
|
|
prdy_vrss_sign?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 17:16:41 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 14:20:07 +09:00
|
|
|
export interface DomesticBalanceSummary {
|
|
|
|
|
totalAmount: number;
|
|
|
|
|
cashBalance: number;
|
2026-02-13 12:17:35 +09:00
|
|
|
totalDepositAmount: number;
|
2026-02-12 14:20:07 +09:00
|
|
|
totalProfitLoss: number;
|
|
|
|
|
totalProfitRate: number;
|
2026-02-12 17:16:41 +09:00
|
|
|
netAssetAmount: number;
|
|
|
|
|
evaluationAmount: number;
|
|
|
|
|
purchaseAmount: number;
|
|
|
|
|
loanAmount: number;
|
2026-02-13 12:17:35 +09:00
|
|
|
apiReportedTotalAmount: number;
|
|
|
|
|
apiReportedNetAssetAmount: number;
|
2026-02-12 14:20:07 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 17:16:41 +09:00
|
|
|
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[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 14:20:07 +09:00
|
|
|
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: "코스닥" },
|
|
|
|
|
];
|
|
|
|
|
|
2026-02-12 17:16:41 +09:00
|
|
|
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" },
|
|
|
|
|
];
|
|
|
|
|
|
2026-02-12 14:20:07 +09: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> {
|
2026-02-12 17:16:41 +09:00
|
|
|
const tradingEnv = normalizeTradingEnv(credentials?.tradingEnv);
|
|
|
|
|
const inquireBalanceTrId = tradingEnv === "real" ? "TTTC8434R" : "VTTC8434R";
|
|
|
|
|
const inquireAccountBalanceTrId =
|
|
|
|
|
tradingEnv === "real" ? "CTRP6548R" : "VTRP6548R";
|
2026-02-12 14:20:07 +09:00
|
|
|
|
2026-02-12 17:16:41 +09:00
|
|
|
const [balanceResponse, accountBalanceResponse] = await Promise.all([
|
|
|
|
|
getDomesticInquireBalanceEnvelope(account, inquireBalanceTrId, credentials),
|
|
|
|
|
getDomesticAccountBalanceSummaryRow(
|
|
|
|
|
account,
|
|
|
|
|
inquireAccountBalanceTrId,
|
|
|
|
|
credentials,
|
|
|
|
|
),
|
|
|
|
|
]);
|
2026-02-12 14:20:07 +09:00
|
|
|
|
2026-02-12 17:16:41 +09:00
|
|
|
const holdingRows = parseRows<KisBalanceOutput1Row>(balanceResponse.output1);
|
|
|
|
|
const summaryRow = parseFirstRow<KisBalanceOutput2Row>(balanceResponse.output2);
|
2026-02-12 14:20:07 +09:00
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
const normalizedHoldings = holdingRows
|
2026-02-12 14:20:07 +09:00
|
|
|
.map((row) => {
|
|
|
|
|
const symbol = (row.pdno ?? "").trim();
|
|
|
|
|
if (!/^\d{6}$/.test(symbol)) return null;
|
2026-02-13 12:17:35 +09:00
|
|
|
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),
|
|
|
|
|
);
|
2026-02-12 14:20:07 +09:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
symbol,
|
|
|
|
|
name: (row.prdt_name ?? "").trim() || symbol,
|
|
|
|
|
market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI",
|
2026-02-13 12:17:35 +09:00
|
|
|
quantity,
|
|
|
|
|
averagePrice,
|
|
|
|
|
currentPrice,
|
|
|
|
|
evaluationAmount,
|
|
|
|
|
profitLoss,
|
|
|
|
|
profitRate,
|
|
|
|
|
purchaseAmountBase,
|
|
|
|
|
} satisfies DomesticHoldingItem & { purchaseAmountBase: number };
|
2026-02-12 14:20:07 +09:00
|
|
|
})
|
2026-02-13 12:17:35 +09:00
|
|
|
.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),
|
2026-02-12 17:16:41 +09:00
|
|
|
);
|
2026-02-13 12:17:35 +09:00
|
|
|
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),
|
2026-02-12 14:20:07 +09:00
|
|
|
holdingsEvalAmount,
|
|
|
|
|
);
|
2026-02-13 12:17:35 +09:00
|
|
|
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,
|
|
|
|
|
),
|
2026-02-12 17:16:41 +09:00
|
|
|
);
|
2026-02-13 12:17:35 +09:00
|
|
|
const loanAmount = pickPreferredAmount(
|
|
|
|
|
toOptionalNumber(accountBalanceResponse?.loan_amt_smtl),
|
2026-02-12 17:16:41 +09:00
|
|
|
);
|
2026-02-13 12:17:35 +09:00
|
|
|
// KIS 원본 총자산(대출/미수 포함 가능)과 순자산(실제 체감 자산)을 분리해 관리합니다.
|
|
|
|
|
const grossTotalAmount = pickPreferredAmount(
|
|
|
|
|
toOptionalNumber(summaryRow?.tot_evlu_amt),
|
|
|
|
|
apiReportedTotalAmount,
|
2026-02-12 17:16:41 +09:00
|
|
|
evaluationAmount + cashBalance,
|
2026-02-12 14:20:07 +09:00
|
|
|
);
|
2026-02-13 12:17:35 +09:00
|
|
|
const netAssetAmount = pickPreferredAmount(
|
|
|
|
|
apiReportedNetAssetAmount,
|
|
|
|
|
grossTotalAmount - loanAmount,
|
|
|
|
|
);
|
|
|
|
|
// 상단 "내 자산"은 순자산 기준(사용자 체감 자산)으로 표시합니다.
|
|
|
|
|
const totalAmount = pickPreferredAmount(
|
|
|
|
|
netAssetAmount,
|
|
|
|
|
grossTotalAmount,
|
|
|
|
|
);
|
|
|
|
|
const totalProfitLoss = pickNonZeroNumber(
|
2026-02-12 17:16:41 +09:00
|
|
|
toOptionalNumber(accountBalanceResponse?.evlu_pfls_amt_smtl),
|
2026-02-12 14:20:07 +09:00
|
|
|
toOptionalNumber(summaryRow?.evlu_pfls_smtl_amt),
|
|
|
|
|
sumNumbers(holdings.map((item) => item.profitLoss)),
|
|
|
|
|
);
|
2026-02-13 12:17:35 +09:00
|
|
|
const totalProfitRate = calcProfitRateByPurchase(totalProfitLoss, purchaseAmount);
|
2026-02-12 14:20:07 +09:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
summary: {
|
|
|
|
|
totalAmount,
|
|
|
|
|
cashBalance,
|
2026-02-13 12:17:35 +09:00
|
|
|
totalDepositAmount,
|
2026-02-12 14:20:07 +09:00
|
|
|
totalProfitLoss,
|
|
|
|
|
totalProfitRate,
|
2026-02-12 17:16:41 +09:00
|
|
|
netAssetAmount,
|
|
|
|
|
evaluationAmount,
|
|
|
|
|
purchaseAmount,
|
|
|
|
|
loanAmount,
|
2026-02-13 12:17:35 +09:00
|
|
|
apiReportedTotalAmount,
|
|
|
|
|
apiReportedNetAssetAmount,
|
2026-02-12 14:20:07 +09:00
|
|
|
},
|
|
|
|
|
holdings,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 17:16:41 +09:00
|
|
|
/**
|
|
|
|
|
* 주식잔고조회(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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 14:20:07 +09:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 17:16:41 +09:00
|
|
|
/**
|
|
|
|
|
* 대시보드 하단의 주문내역/매매일지 데이터를 조회합니다.
|
|
|
|
|
* @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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 14:20:07 +09:00
|
|
|
/**
|
|
|
|
|
* 문자열 숫자를 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
2026-02-13 12:17:35 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 매입금액 대비 손익률을 계산합니다.
|
|
|
|
|
* @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);
|
|
|
|
|
}
|