337 lines
9.7 KiB
TypeScript
337 lines
9.7 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";
|
|
|
|
interface KisBalanceOutput1Row {
|
|
pdno?: string;
|
|
prdt_name?: string;
|
|
hldg_qty?: string;
|
|
pchs_avg_pric?: 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;
|
|
evlu_amt_smtl_amt?: string;
|
|
evlu_pfls_smtl_amt?: string;
|
|
asst_icdc_erng_rt?: string;
|
|
}
|
|
|
|
interface KisIndexOutputRow {
|
|
bstp_nmix_prpr?: string;
|
|
bstp_nmix_prdy_vrss?: string;
|
|
bstp_nmix_prdy_ctrt?: string;
|
|
prdy_vrss_sign?: string;
|
|
}
|
|
|
|
export interface DomesticBalanceSummary {
|
|
totalAmount: number;
|
|
cashBalance: number;
|
|
totalProfitLoss: number;
|
|
totalProfitRate: 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;
|
|
}
|
|
|
|
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: "코스닥" },
|
|
];
|
|
|
|
/**
|
|
* 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 trId =
|
|
normalizeTradingEnv(credentials?.tradingEnv) === "real"
|
|
? "TTTC8434R"
|
|
: "VTTC8434R";
|
|
|
|
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 holdingRows = parseRows<KisBalanceOutput1Row>(response.output1);
|
|
const summaryRow = parseFirstRow<KisBalanceOutput2Row>(response.output2);
|
|
|
|
const holdings = holdingRows
|
|
.map((row) => {
|
|
const symbol = (row.pdno ?? "").trim();
|
|
if (!/^\d{6}$/.test(symbol)) return null;
|
|
|
|
return {
|
|
symbol,
|
|
name: (row.prdt_name ?? "").trim() || symbol,
|
|
market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI",
|
|
quantity: toNumber(row.hldg_qty),
|
|
averagePrice: toNumber(row.pchs_avg_pric),
|
|
currentPrice: toNumber(row.prpr),
|
|
evaluationAmount: toNumber(row.evlu_amt),
|
|
profitLoss: toNumber(row.evlu_pfls_amt),
|
|
profitRate: toNumber(row.evlu_pfls_rt),
|
|
} satisfies DomesticHoldingItem;
|
|
})
|
|
.filter((item): item is DomesticHoldingItem => Boolean(item));
|
|
|
|
const cashBalance = toNumber(summaryRow?.dnca_tot_amt);
|
|
const holdingsEvalAmount = sumNumbers(holdings.map((item) => item.evaluationAmount));
|
|
const stockEvalAmount = firstPositiveNumber(
|
|
toNumber(summaryRow?.scts_evlu_amt),
|
|
toNumber(summaryRow?.evlu_amt_smtl_amt),
|
|
holdingsEvalAmount,
|
|
);
|
|
const totalAmount = firstPositiveNumber(
|
|
stockEvalAmount + cashBalance,
|
|
toNumber(summaryRow?.tot_evlu_amt),
|
|
holdingsEvalAmount + cashBalance,
|
|
);
|
|
const totalProfitLoss = firstDefinedNumber(
|
|
toOptionalNumber(summaryRow?.evlu_pfls_smtl_amt),
|
|
sumNumbers(holdings.map((item) => item.profitLoss)),
|
|
);
|
|
const totalProfitRate = firstDefinedNumber(
|
|
toOptionalNumber(summaryRow?.asst_icdc_erng_rt),
|
|
calcProfitRate(totalProfitLoss, totalAmount),
|
|
);
|
|
|
|
return {
|
|
summary: {
|
|
totalAmount,
|
|
cashBalance,
|
|
totalProfitLoss,
|
|
totalProfitRate,
|
|
},
|
|
holdings,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* KOSPI/KOSDAQ 지수를 조회해 대시보드 모델로 변환합니다.
|
|
* @param credentials 사용자 입력 키(선택)
|
|
* @returns 지수 목록(코스피/코스닥)
|
|
* @see app/api/kis/domestic/indices/route.ts 대시보드 지수 API 응답 생성
|
|
*/
|
|
export async function getDomesticDashboardIndices(
|
|
credentials?: KisCredentialInput,
|
|
): Promise<DomesticMarketIndexResult[]> {
|
|
const results = await Promise.all(
|
|
INDEX_TARGETS.map(async (target) => {
|
|
const response = await kisGet<KisIndexOutputRow>(
|
|
"/uapi/domestic-stock/v1/quotations/inquire-index-price",
|
|
"FHPUP02100000",
|
|
{
|
|
FID_COND_MRKT_DIV_CODE: "U",
|
|
FID_INPUT_ISCD: target.code,
|
|
},
|
|
credentials,
|
|
);
|
|
|
|
const row = parseIndexRow(response.output);
|
|
const rawChange = toNumber(row.bstp_nmix_prdy_vrss);
|
|
const rawChangeRate = toNumber(row.bstp_nmix_prdy_ctrt);
|
|
|
|
return {
|
|
market: target.market,
|
|
code: target.code,
|
|
name: target.name,
|
|
price: toNumber(row.bstp_nmix_prpr),
|
|
change: normalizeSignedValue(rawChange, row.prdy_vrss_sign),
|
|
changeRate: normalizeSignedValue(rawChangeRate, row.prdy_vrss_sign),
|
|
} satisfies DomesticMarketIndexResult;
|
|
}),
|
|
);
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* 문자열 숫자를 number로 변환합니다.
|
|
* @param value KIS 숫자 문자열
|
|
* @returns 파싱된 숫자(실패 시 0)
|
|
* @see lib/kis/dashboard.ts 잔고/지수 필드 공통 파싱
|
|
*/
|
|
function toNumber(value?: string) {
|
|
if (!value) return 0;
|
|
const normalized = value.replaceAll(",", "").trim();
|
|
if (!normalized) return 0;
|
|
const parsed = Number(normalized);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
|
|
/**
|
|
* 문자열 숫자를 number로 변환하되 0도 유효값으로 유지합니다.
|
|
* @param value KIS 숫자 문자열
|
|
* @returns 파싱된 숫자 또는 undefined
|
|
* @see lib/kis/dashboard.ts 요약값 폴백 순서 계산
|
|
*/
|
|
function toOptionalNumber(value?: string) {
|
|
if (!value) return undefined;
|
|
const normalized = value.replaceAll(",", "").trim();
|
|
if (!normalized) return undefined;
|
|
const parsed = Number(normalized);
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
}
|
|
|
|
/**
|
|
* output 계열 데이터를 배열 형태로 변환합니다.
|
|
* @param value KIS output 값
|
|
* @returns 레코드 배열
|
|
* @see lib/kis/dashboard.ts 잔고 output1/output2 파싱
|
|
*/
|
|
function parseRows<T>(value: unknown): T[] {
|
|
if (Array.isArray(value)) return value as T[];
|
|
if (value && typeof value === "object") return [value as T];
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* output 계열 데이터의 첫 행을 반환합니다.
|
|
* @param value KIS output 값
|
|
* @returns 첫 번째 레코드
|
|
* @see lib/kis/dashboard.ts 잔고 요약(output2) 파싱
|
|
*/
|
|
function parseFirstRow<T>(value: unknown) {
|
|
const rows = parseRows<T>(value);
|
|
return rows[0];
|
|
}
|
|
|
|
/**
|
|
* 지수 output을 단일 레코드로 정규화합니다.
|
|
* @param output KIS output
|
|
* @returns 지수 레코드
|
|
* @see lib/kis/dashboard.ts getDomesticDashboardIndices
|
|
*/
|
|
function parseIndexRow(output: unknown): KisIndexOutputRow {
|
|
if (Array.isArray(output) && output[0] && typeof output[0] === "object") {
|
|
return output[0] as KisIndexOutputRow;
|
|
}
|
|
if (output && typeof output === "object") {
|
|
return output as KisIndexOutputRow;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/**
|
|
* KIS 부호 코드(1/2 상승, 4/5 하락)를 실제 부호로 반영합니다.
|
|
* @param value 변동값
|
|
* @param signCode 부호 코드
|
|
* @returns 부호 적용 숫자
|
|
* @see lib/kis/dashboard.ts getDomesticDashboardIndices
|
|
*/
|
|
function normalizeSignedValue(value: number, signCode?: string) {
|
|
const abs = Math.abs(value);
|
|
if (signCode === "4" || signCode === "5") return -abs;
|
|
if (signCode === "1" || signCode === "2") return abs;
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* 양수 우선값을 반환합니다.
|
|
* @param values 후보 숫자 목록
|
|
* @returns 첫 번째 양수, 없으면 0
|
|
* @see lib/kis/dashboard.ts 요약 금액 폴백 계산
|
|
*/
|
|
function firstPositiveNumber(...values: number[]) {
|
|
return values.find((value) => value > 0) ?? 0;
|
|
}
|
|
|
|
/**
|
|
* undefined가 아닌 첫 값을 반환합니다.
|
|
* @param values 후보 숫자 목록
|
|
* @returns 첫 번째 유효값, 없으면 0
|
|
* @see lib/kis/dashboard.ts 요약 수익률/손익 폴백 계산
|
|
*/
|
|
function firstDefinedNumber(...values: Array<number | undefined>) {
|
|
return values.find((value) => value !== undefined) ?? 0;
|
|
}
|
|
|
|
/**
|
|
* 숫자 배열 합계를 계산합니다.
|
|
* @param values 숫자 배열
|
|
* @returns 합계
|
|
* @see lib/kis/dashboard.ts 보유종목 합계 계산
|
|
*/
|
|
function sumNumbers(values: number[]) {
|
|
return values.reduce((total, value) => total + value, 0);
|
|
}
|
|
|
|
/**
|
|
* 총자산 대비 손익률을 계산합니다.
|
|
* @param profit 손익 금액
|
|
* @param totalAmount 총자산 금액
|
|
* @returns 손익률(%)
|
|
* @see lib/kis/dashboard.ts 요약 수익률 폴백 계산
|
|
*/
|
|
function calcProfitRate(profit: number, totalAmount: number) {
|
|
if (totalAmount <= 0) return 0;
|
|
const baseAmount = totalAmount - profit;
|
|
if (baseAmount <= 0) return 0;
|
|
return (profit / baseAmount) * 100;
|
|
}
|