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

337 lines
9.7 KiB
TypeScript
Raw 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";
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;
}