전체적인 리팩토링
This commit is contained in:
@@ -35,6 +35,7 @@ interface KisBalanceOutput1Row {
|
||||
pdno?: string;
|
||||
prdt_name?: string;
|
||||
hldg_qty?: string;
|
||||
ord_psbl_qty?: string;
|
||||
pchs_avg_pric?: string;
|
||||
pchs_amt?: string;
|
||||
prpr?: string;
|
||||
@@ -72,6 +73,45 @@ interface KisIndexOutputRow {
|
||||
prdy_vrss_sign?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -133,6 +173,7 @@ export interface DomesticHoldingItem {
|
||||
name: string;
|
||||
market: "KOSPI" | "KOSDAQ";
|
||||
quantity: number;
|
||||
sellableQuantity: number;
|
||||
averagePrice: number;
|
||||
currentPrice: number;
|
||||
evaluationAmount: number;
|
||||
@@ -154,6 +195,44 @@ export interface DomesticMarketIndexResult {
|
||||
changeRate: number;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -217,6 +296,7 @@ const INDEX_TARGETS: Array<{
|
||||
|
||||
const DASHBOARD_ORDER_LOOKBACK_DAYS = 30;
|
||||
const DASHBOARD_JOURNAL_LOOKBACK_DAYS = 90;
|
||||
const DASHBOARD_MARKET_HUB_LIMIT = 10;
|
||||
|
||||
interface DashboardBalanceInquirePreset {
|
||||
inqrDvsn: "01" | "02";
|
||||
@@ -263,6 +343,7 @@ export async function getDomesticDashboardBalance(
|
||||
const symbol = (row.pdno ?? "").trim();
|
||||
if (!/^\d{6}$/.test(symbol)) return null;
|
||||
const quantity = toNumber(row.hldg_qty);
|
||||
const sellableQuantity = Math.max(0, Math.floor(toNumber(row.ord_psbl_qty)));
|
||||
const averagePrice = toNumber(row.pchs_avg_pric);
|
||||
const currentPrice = toNumber(row.prpr);
|
||||
const purchaseAmountBase = pickPreferredAmount(
|
||||
@@ -287,6 +368,7 @@ export async function getDomesticDashboardBalance(
|
||||
name: (row.prdt_name ?? "").trim() || symbol,
|
||||
market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI",
|
||||
quantity,
|
||||
sellableQuantity,
|
||||
averagePrice,
|
||||
currentPrice,
|
||||
evaluationAmount,
|
||||
@@ -296,14 +378,18 @@ export async function getDomesticDashboardBalance(
|
||||
} satisfies DomesticHoldingItem & { purchaseAmountBase: number };
|
||||
})
|
||||
.filter(
|
||||
(item): item is DomesticHoldingItem & { purchaseAmountBase: number } =>
|
||||
Boolean(item),
|
||||
(item): item is DomesticHoldingItem & { purchaseAmountBase: number } => {
|
||||
if (!item) return false;
|
||||
// [Step 3] 전량 매도 후 잔존(수량 0) 레코드는 대시보드 보유종목에서 제외합니다.
|
||||
return item.quantity > 0;
|
||||
},
|
||||
);
|
||||
const holdings = normalizedHoldings.map((item) => ({
|
||||
symbol: item.symbol,
|
||||
name: item.name,
|
||||
market: item.market,
|
||||
quantity: item.quantity,
|
||||
sellableQuantity: item.sellableQuantity,
|
||||
averagePrice: item.averagePrice,
|
||||
currentPrice: item.currentPrice,
|
||||
evaluationAmount: item.evaluationAmount,
|
||||
@@ -518,6 +604,367 @@ export async function getDomesticDashboardIndices(
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 급등/인기/뉴스 시장 허브 데이터를 조회합니다.
|
||||
* @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) 파트
|
||||
|
||||
Reference in New Issue
Block a user