805 lines
24 KiB
TypeScript
805 lines
24 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";
|
|
import {
|
|
calcProfitRate,
|
|
calcProfitRateByPurchase,
|
|
createEmptyJournalSummary,
|
|
firstDefinedNumber,
|
|
formatDateLabel,
|
|
formatTimeLabel,
|
|
getLookbackRangeYmd,
|
|
normalizeSignedValue,
|
|
normalizeTimeDigits,
|
|
parseFirstRow,
|
|
parseIndexRow,
|
|
parseRows,
|
|
parseTradeSide,
|
|
pickNonZeroNumber,
|
|
pickPreferredAmount,
|
|
resolveCashBalance,
|
|
sumNumbers,
|
|
toDigits,
|
|
toNumber,
|
|
toOptionalNumber,
|
|
} from "@/lib/kis/dashboard-helpers";
|
|
|
|
interface KisBalanceOutput1Row {
|
|
pdno?: string;
|
|
prdt_name?: string;
|
|
hldg_qty?: string;
|
|
pchs_avg_pric?: string;
|
|
pchs_amt?: 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;
|
|
nass_amt?: string;
|
|
pchs_amt_smtl_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;
|
|
totalDepositAmount: number;
|
|
totalProfitLoss: number;
|
|
totalProfitRate: number;
|
|
netAssetAmount: number;
|
|
evaluationAmount: number;
|
|
purchaseAmount: number;
|
|
loanAmount: number;
|
|
apiReportedTotalAmount: number;
|
|
apiReportedNetAssetAmount: 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 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,
|
|
averagePrice,
|
|
currentPrice,
|
|
evaluationAmount,
|
|
profitLoss,
|
|
profitRate,
|
|
purchaseAmountBase,
|
|
} satisfies DomesticHoldingItem & { purchaseAmountBase: number };
|
|
})
|
|
.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),
|
|
);
|
|
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 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,
|
|
),
|
|
);
|
|
const loanAmount = pickPreferredAmount(
|
|
toOptionalNumber(accountBalanceResponse?.loan_amt_smtl),
|
|
);
|
|
// KIS 원본 총자산(대출/미수 포함 가능)과 순자산(실제 체감 자산)을 분리해 관리합니다.
|
|
const grossTotalAmount = pickPreferredAmount(
|
|
toOptionalNumber(summaryRow?.tot_evlu_amt),
|
|
apiReportedTotalAmount,
|
|
evaluationAmount + cashBalance,
|
|
);
|
|
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 = calcProfitRateByPurchase(totalProfitLoss, purchaseAmount);
|
|
|
|
return {
|
|
summary: {
|
|
totalAmount,
|
|
cashBalance,
|
|
totalDepositAmount,
|
|
totalProfitLoss,
|
|
totalProfitRate,
|
|
netAssetAmount,
|
|
evaluationAmount,
|
|
purchaseAmount,
|
|
loanAmount,
|
|
apiReportedTotalAmount,
|
|
apiReportedNetAssetAmount,
|
|
},
|
|
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<KisIndexOutputRow>(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,
|
|
};
|
|
}
|