Files
auto-trade/lib/kis/dashboard.ts

1252 lines
37 KiB
TypeScript
Raw Permalink Normal View History

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";
2026-02-26 09:05:17 +09:00
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";
2026-02-12 14:20:07 +09:00
interface KisBalanceOutput1Row {
pdno?: string;
prdt_name?: string;
hldg_qty?: string;
2026-03-12 09:26:27 +09:00
ord_psbl_qty?: string;
2026-02-12 14:20:07 +09:00
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;
}
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-03-12 09:26:27 +09:00
interface KisFluctuationOutputRow {
data_rank?: string;
stck_shrn_iscd?: string;
hts_kor_isnm?: string;
stck_prpr?: string;
prdy_vrss?: string;
prdy_vrss_sign?: string;
prdy_ctrt?: string;
acml_vol?: string;
acml_tr_pbmn?: string;
}
interface KisVolumeRankOutputRow {
data_rank?: string;
mksc_shrn_iscd?: string;
stck_shrn_iscd?: string;
hts_kor_isnm?: string;
stck_prpr?: string;
prdy_vrss?: string;
prdy_vrss_sign?: string;
prdy_ctrt?: string;
acml_vol?: string;
acml_tr_pbmn?: string;
}
interface KisNewsTitleOutputRow {
cntt_usiq_srno?: string;
data_dt?: string;
data_tm?: string;
hts_pbnt_titl_cntt?: string;
dorg?: string;
news_ofer_entp_code?: string;
iscd1?: string;
iscd2?: string;
iscd3?: string;
iscd4?: string;
iscd5?: 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;
}
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;
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;
2026-03-12 09:26:27 +09:00
sellableQuantity: number;
2026-02-12 14:20:07 +09:00
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-03-12 09:26:27 +09:00
export interface DomesticMarketRankItem {
rank: number;
symbol: string;
name: string;
market: "KOSPI" | "KOSDAQ";
price: number;
change: number;
changeRate: number;
volume: number;
tradingValue: number;
}
export interface DomesticNewsHeadlineItem {
id: string;
title: string;
source: string;
publishedAt: string;
symbols: string[];
}
export interface DomesticMarketPulse {
gainersCount: number;
losersCount: number;
popularByVolumeCount: number;
popularByValueCount: number;
newsCount: number;
}
export interface DomesticDashboardMarketHubResult {
gainers: DomesticMarketRankItem[];
losers: DomesticMarketRankItem[];
popularByVolume: DomesticMarketRankItem[];
popularByValue: DomesticMarketRankItem[];
news: DomesticNewsHeadlineItem[];
pulse: DomesticMarketPulse;
warnings: string[];
}
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: "코스닥" },
];
const DASHBOARD_ORDER_LOOKBACK_DAYS = 30;
const DASHBOARD_JOURNAL_LOOKBACK_DAYS = 90;
2026-03-12 09:26:27 +09:00
const DASHBOARD_MARKET_HUB_LIMIT = 10;
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> {
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
const [balanceResponse, accountBalanceResponse] = await Promise.all([
getDomesticInquireBalanceEnvelope(account, inquireBalanceTrId, credentials),
getDomesticAccountBalanceSummaryRow(
account,
inquireAccountBalanceTrId,
credentials,
),
]);
2026-02-12 14:20:07 +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);
2026-03-12 09:26:27 +09:00
const sellableQuantity = Math.max(0, Math.floor(toNumber(row.ord_psbl_qty)));
2026-02-13 12:17:35 +09:00
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,
2026-03-12 09:26:27 +09:00
sellableQuantity,
2026-02-13 12:17:35 +09:00
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(
2026-03-12 09:26:27 +09:00
(item): item is DomesticHoldingItem & { purchaseAmountBase: number } => {
if (!item) return false;
// [Step 3] 전량 매도 후 잔존(수량 0) 레코드는 대시보드 보유종목에서 제외합니다.
return item.quantity > 0;
},
2026-02-13 12:17:35 +09:00
);
const holdings = normalizedHoldings.map((item) => ({
symbol: item.symbol,
name: item.name,
market: item.market,
quantity: item.quantity,
2026-03-12 09:26:27 +09:00
sellableQuantity: item.sellableQuantity,
2026-02-13 12:17:35 +09:00
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-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-13 12:17:35 +09:00
const loanAmount = pickPreferredAmount(
toOptionalNumber(accountBalanceResponse?.loan_amt_smtl),
);
2026-02-13 12:17:35 +09:00
// KIS 원본 총자산(대출/미수 포함 가능)과 순자산(실제 체감 자산)을 분리해 관리합니다.
const grossTotalAmount = pickPreferredAmount(
toOptionalNumber(summaryRow?.tot_evlu_amt),
apiReportedTotalAmount,
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(
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,
netAssetAmount,
evaluationAmount,
purchaseAmount,
loanAmount,
2026-02-13 12:17:35 +09:00
apiReportedTotalAmount,
apiReportedNetAssetAmount,
2026-02-12 14:20:07 +09:00
},
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;
}
}
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,
);
2026-02-26 09:05:17 +09:00
const row = parseIndexRow<KisIndexOutputRow>(response.output);
2026-02-12 14:20:07 +09:00
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-03-12 09:26:27 +09:00
/**
* // .
* @param credentials ()
* @returns //
* @remarks UI : /dashboard -> market-hub API ->
* @see app/api/kis/domestic/market-hub/route.ts API
*/
export async function getDomesticDashboardMarketHub(
credentials?: KisCredentialInput,
): Promise<DomesticDashboardMarketHubResult> {
const [
gainersResult,
losersResult,
popularVolumeResult,
popularValueResult,
newsResult,
] =
await Promise.allSettled([
getDomesticTopGainers(credentials),
getDomesticTopLosers(credentials),
getDomesticVolumeRank(credentials, "0"),
getDomesticVolumeRank(credentials, "3"),
getDomesticNewsHeadlines(credentials),
]);
const warnings: string[] = [];
const gainers =
gainersResult.status === "fulfilled" ? gainersResult.value : [];
if (gainersResult.status === "rejected") {
warnings.push(
gainersResult.reason instanceof Error
? `급등주식 조회 실패: ${gainersResult.reason.message}`
: "급등주식 조회에 실패했습니다.",
);
}
const losers = losersResult.status === "fulfilled" ? losersResult.value : [];
if (losersResult.status === "rejected") {
warnings.push(
losersResult.reason instanceof Error
? `급락주식 조회 실패: ${losersResult.reason.message}`
: "급락주식 조회에 실패했습니다.",
);
}
const popularByVolume =
popularVolumeResult.status === "fulfilled" ? popularVolumeResult.value : [];
if (popularVolumeResult.status === "rejected") {
warnings.push(
popularVolumeResult.reason instanceof Error
? `인기종목(거래량) 조회 실패: ${popularVolumeResult.reason.message}`
: "인기종목(거래량) 조회에 실패했습니다.",
);
}
const popularByValue =
popularValueResult.status === "fulfilled" ? popularValueResult.value : [];
if (popularValueResult.status === "rejected") {
warnings.push(
popularValueResult.reason instanceof Error
? `거래대금 상위 조회 실패: ${popularValueResult.reason.message}`
: "거래대금 상위 조회에 실패했습니다.",
);
}
const news = newsResult.status === "fulfilled" ? newsResult.value : [];
if (newsResult.status === "rejected") {
warnings.push(
newsResult.reason instanceof Error
? `주요 뉴스 조회 실패: ${newsResult.reason.message}`
: "주요 뉴스 조회에 실패했습니다.",
);
}
if (
gainersResult.status === "rejected" &&
losersResult.status === "rejected" &&
popularVolumeResult.status === "rejected" &&
popularValueResult.status === "rejected" &&
newsResult.status === "rejected"
) {
throw new Error("시장 허브 데이터를 모두 조회하지 못했습니다.");
}
return {
gainers,
losers,
popularByVolume,
popularByValue,
news,
pulse: {
gainersCount: gainers.length,
losersCount: losers.length,
popularByVolumeCount: popularByVolume.length,
popularByValueCount: popularByValue.length,
newsCount: news.length,
},
warnings,
};
}
async function getDomesticTopGainers(credentials?: KisCredentialInput) {
const fluctuationRows = await getDomesticFluctuationRows(credentials);
const fluctuationMapped = fluctuationRows
.map((row, index) => normalizeMarketRankItemFromFluctuation(row, index))
.filter((item): item is DomesticMarketRankItem => Boolean(item))
.filter((item) => item.changeRate > 0)
.sort((a, b) => b.changeRate - a.changeRate);
if (fluctuationMapped.length >= DASHBOARD_MARKET_HUB_LIMIT) {
return fluctuationMapped.slice(0, DASHBOARD_MARKET_HUB_LIMIT);
}
// 변동률 순위 API가 빈 응답일 때 거래량 상위 중 상승률 상위로 폴백합니다.
const fallbackRows = await getDomesticVolumeRank(credentials, "0");
const fallbackGainers = fallbackRows
.filter((item) => item.changeRate > 0)
.sort((a, b) => b.changeRate - a.changeRate);
return dedupeMarketRankItems([...fluctuationMapped, ...fallbackGainers]).slice(
0,
DASHBOARD_MARKET_HUB_LIMIT,
);
}
async function getDomesticTopLosers(credentials?: KisCredentialInput) {
const fluctuationRows = await getDomesticFluctuationRows(credentials);
const fluctuationMapped = fluctuationRows
.map((row, index) => normalizeMarketRankItemFromFluctuation(row, index))
.filter((item): item is DomesticMarketRankItem => Boolean(item))
.filter((item) => item.changeRate < 0)
.sort((a, b) => a.changeRate - b.changeRate);
if (fluctuationMapped.length >= DASHBOARD_MARKET_HUB_LIMIT) {
return fluctuationMapped.slice(0, DASHBOARD_MARKET_HUB_LIMIT);
}
const fallbackRows = await getDomesticVolumeRank(credentials, "0");
const fallbackLosers = fallbackRows
.filter((item) => item.changeRate < 0)
.sort((a, b) => a.changeRate - b.changeRate);
return dedupeMarketRankItems([...fluctuationMapped, ...fallbackLosers]).slice(
0,
DASHBOARD_MARKET_HUB_LIMIT,
);
}
async function getDomesticVolumeRank(
credentials: KisCredentialInput | undefined,
sortClassCode: "0" | "3",
) {
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/quotations/volume-rank",
"FHPST01710000",
{
FID_COND_MRKT_DIV_CODE: "J",
FID_COND_SCR_DIV_CODE: "20171",
FID_INPUT_ISCD: "0000",
FID_DIV_CLS_CODE: "0",
FID_BLNG_CLS_CODE: sortClassCode,
FID_TRGT_CLS_CODE: "111111111",
FID_TRGT_EXLS_CLS_CODE: "0000000000",
FID_INPUT_PRICE_1: "",
FID_INPUT_PRICE_2: "",
FID_VOL_CNT: "",
FID_INPUT_DATE_1: "",
},
credentials,
);
const rows = parseRows<KisVolumeRankOutputRow>(response.output);
return rows
.map((row, index) => normalizeMarketRankItemFromVolume(row, index))
.filter((item): item is DomesticMarketRankItem => Boolean(item))
.slice(0, DASHBOARD_MARKET_HUB_LIMIT);
}
async function getDomesticNewsHeadlines(credentials?: KisCredentialInput) {
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/quotations/news-title",
"FHKST01011800",
{
FID_NEWS_OFER_ENTP_CODE: "",
FID_COND_MRKT_CLS_CODE: "",
FID_INPUT_ISCD: "",
FID_TITL_CNTT: "",
FID_INPUT_DATE_1: "",
FID_INPUT_HOUR_1: "",
FID_RANK_SORT_CLS_CODE: "",
FID_INPUT_SRNO: "",
},
credentials,
);
const rows = parseRows<KisNewsTitleOutputRow>(response.output);
return rows
.map((row, index) => normalizeNewsHeadlineItem(row, index))
.filter((item): item is DomesticNewsHeadlineItem => Boolean(item))
.slice(0, DASHBOARD_MARKET_HUB_LIMIT);
}
async function getDomesticFluctuationRows(credentials?: KisCredentialInput) {
const presets = [
{
fid_rank_sort_cls_code: "0000",
fid_trgt_cls_code: "0",
fid_trgt_exls_cls_code: "0",
fid_rsfl_rate1: "",
fid_rsfl_rate2: "",
},
{
fid_rank_sort_cls_code: "0",
fid_trgt_cls_code: "111111111",
fid_trgt_exls_cls_code: "0000000000",
fid_rsfl_rate1: "",
fid_rsfl_rate2: "",
},
] as const;
const errors: string[] = [];
for (const preset of presets) {
try {
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/ranking/fluctuation",
"FHPST01700000",
{
fid_cond_mrkt_div_code: "J",
fid_cond_scr_div_code: "20170",
fid_input_iscd: "0000",
fid_rank_sort_cls_code: preset.fid_rank_sort_cls_code,
fid_input_cnt_1: "0",
fid_prc_cls_code: "0",
fid_input_price_1: "",
fid_input_price_2: "",
fid_vol_cnt: "",
fid_trgt_cls_code: preset.fid_trgt_cls_code,
fid_trgt_exls_cls_code: preset.fid_trgt_exls_cls_code,
fid_div_cls_code: "0",
fid_rsfl_rate1: preset.fid_rsfl_rate1,
fid_rsfl_rate2: preset.fid_rsfl_rate2,
},
credentials,
);
const rows = parseRows<KisFluctuationOutputRow>(response.output);
if (rows.length > 0) {
return rows;
}
} catch (error) {
errors.push(error instanceof Error ? error.message : "호출 실패");
}
}
if (errors.length > 0) {
throw new Error(errors.join(" | "));
}
return [] as KisFluctuationOutputRow[];
}
function normalizeMarketRankItemFromFluctuation(
row: KisFluctuationOutputRow,
index: number,
) {
const symbol = (row.stck_shrn_iscd ?? "").trim();
if (!/^\d{6}$/.test(symbol)) return null;
const signedChange = normalizeSignedValue(
toNumber(row.prdy_vrss),
row.prdy_vrss_sign,
);
const signedChangeRate = normalizeSignedValue(
toNumber(row.prdy_ctrt),
row.prdy_vrss_sign,
);
return {
rank: resolveRank(row.data_rank, index),
symbol,
name: (row.hts_kor_isnm ?? "").trim() || symbol,
market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI",
price: toNumber(row.stck_prpr),
change: signedChange,
changeRate: signedChangeRate,
volume: toNumber(row.acml_vol),
tradingValue: toNumber(row.acml_tr_pbmn),
} satisfies DomesticMarketRankItem;
}
function normalizeMarketRankItemFromVolume(
row: KisVolumeRankOutputRow,
index: number,
) {
const symbol = (row.mksc_shrn_iscd ?? row.stck_shrn_iscd ?? "").trim();
if (!/^\d{6}$/.test(symbol)) return null;
const signedChange = normalizeSignedValue(
toNumber(row.prdy_vrss),
row.prdy_vrss_sign,
);
const signedChangeRate = normalizeSignedValue(
toNumber(row.prdy_ctrt),
row.prdy_vrss_sign,
);
return {
rank: resolveRank(row.data_rank, index),
symbol,
name: (row.hts_kor_isnm ?? "").trim() || symbol,
market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI",
price: toNumber(row.stck_prpr),
change: signedChange,
changeRate: signedChangeRate,
volume: toNumber(row.acml_vol),
tradingValue: toNumber(row.acml_tr_pbmn),
} satisfies DomesticMarketRankItem;
}
function normalizeNewsHeadlineItem(row: KisNewsTitleOutputRow, index: number) {
const title = (row.hts_pbnt_titl_cntt ?? "").trim();
if (!title) return null;
const id = (row.cntt_usiq_srno ?? "").trim();
const symbols = [row.iscd1, row.iscd2, row.iscd3, row.iscd4, row.iscd5]
.map((value) => (value ?? "").trim())
.filter((value, position, all) => /^\d{6}$/.test(value) && all.indexOf(value) === position);
return {
id: id || `news-${index + 1}`,
title,
source: (row.dorg ?? row.news_ofer_entp_code ?? "").trim() || "KIS",
publishedAt: formatNewsTimestamp(row.data_dt, row.data_tm),
symbols,
} satisfies DomesticNewsHeadlineItem;
}
function dedupeMarketRankItems(items: DomesticMarketRankItem[]) {
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.symbol)) return false;
seen.add(item.symbol);
return true;
});
}
function resolveRank(rawRank: string | undefined, fallbackIndex: number) {
const parsed = Math.floor(toNumber(rawRank));
if (parsed > 0) return parsed;
return fallbackIndex + 1;
}
function formatNewsTimestamp(rawDate?: string, rawTime?: string) {
const dateDigits = toDigits(rawDate);
if (dateDigits.length !== 8) return "-";
const timeDigits = normalizeTimeDigits(rawTime);
return `${formatDateLabel(dateDigits)} ${formatTimeLabel(timeDigits)}`;
}
/**
* / .
* @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,
};
}