대시보드 추가기능 + 계좌인증
This commit is contained in:
@@ -29,6 +29,17 @@ interface KisBalanceOutput2Row {
|
||||
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;
|
||||
@@ -36,11 +47,57 @@ interface KisIndexOutputRow {
|
||||
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 {
|
||||
@@ -69,6 +126,54 @@ export interface DomesticMarketIndexResult {
|
||||
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),
|
||||
);
|
||||
@@ -82,6 +187,21 @@ const INDEX_TARGETS: Array<{
|
||||
{ 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) 파트
|
||||
@@ -93,32 +213,22 @@ export async function getDomesticDashboardBalance(
|
||||
account: KisAccountParts,
|
||||
credentials?: KisCredentialInput,
|
||||
): Promise<DomesticBalanceResult> {
|
||||
const trId =
|
||||
normalizeTradingEnv(credentials?.tradingEnv) === "real"
|
||||
? "TTTC8434R"
|
||||
: "VTTC8434R";
|
||||
const tradingEnv = normalizeTradingEnv(credentials?.tradingEnv);
|
||||
const inquireBalanceTrId = tradingEnv === "real" ? "TTTC8434R" : "VTTC8434R";
|
||||
const inquireAccountBalanceTrId =
|
||||
tradingEnv === "real" ? "CTRP6548R" : "VTRP6548R";
|
||||
|
||||
const response = 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: "02",
|
||||
UNPR_DVSN: "01",
|
||||
FUND_STTL_ICLD_YN: "N",
|
||||
FNCG_AMT_AUTO_RDPT_YN: "N",
|
||||
PRCS_DVSN: "00",
|
||||
CTX_AREA_FK100: "",
|
||||
CTX_AREA_NK100: "",
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
const [balanceResponse, accountBalanceResponse] = await Promise.all([
|
||||
getDomesticInquireBalanceEnvelope(account, inquireBalanceTrId, credentials),
|
||||
getDomesticAccountBalanceSummaryRow(
|
||||
account,
|
||||
inquireAccountBalanceTrId,
|
||||
credentials,
|
||||
),
|
||||
]);
|
||||
|
||||
const holdingRows = parseRows<KisBalanceOutput1Row>(response.output1);
|
||||
const summaryRow = parseFirstRow<KisBalanceOutput2Row>(response.output2);
|
||||
const holdingRows = parseRows<KisBalanceOutput1Row>(balanceResponse.output1);
|
||||
const summaryRow = parseFirstRow<KisBalanceOutput2Row>(balanceResponse.output2);
|
||||
|
||||
const holdings = holdingRows
|
||||
.map((row) => {
|
||||
@@ -139,19 +249,36 @@ export async function getDomesticDashboardBalance(
|
||||
})
|
||||
.filter((item): item is DomesticHoldingItem => Boolean(item));
|
||||
|
||||
const cashBalance = toNumber(summaryRow?.dnca_tot_amt);
|
||||
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 stockEvalAmount = firstPositiveNumber(
|
||||
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(
|
||||
stockEvalAmount + cashBalance,
|
||||
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)),
|
||||
);
|
||||
@@ -166,11 +293,95 @@ export async function getDomesticDashboardBalance(
|
||||
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 사용자 입력 키(선택)
|
||||
@@ -210,6 +421,399 @@ export async function getDomesticDashboardIndices(
|
||||
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 숫자 문자열
|
||||
|
||||
Reference in New Issue
Block a user