스킬 정리 및 리팩토링

This commit is contained in:
2026-02-26 09:05:17 +09:00
parent 4c52d6d82f
commit 406af7408a
71 changed files with 3776 additions and 3934 deletions

View File

@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
import type { KisCredentialInput } from "@/lib/kis/config";
import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config";
@@ -60,9 +61,11 @@ async function issueKisApprovalKey(
const payload = tryParseApprovalResponse(rawText);
if (!response.ok || !payload.approval_key) {
const detail = [payload.msg1, payload.error_description, payload.error, payload.msg_cd]
.filter(Boolean)
.join(" / ");
const detail = buildKisErrorDetail({
message: payload.msg1,
msgCode: payload.msg_cd,
extraMessages: [payload.error_description, payload.error],
});
throw new Error(
detail

View File

@@ -1,5 +1,6 @@
import type { KisCredentialInput } from "@/lib/kis/config";
import { getKisConfig } from "@/lib/kis/config";
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
import { getKisAccessToken } from "@/lib/kis/token";
/**
@@ -57,7 +58,11 @@ export async function kisGet<TOutput>(
const payload = tryParseKisEnvelope<TOutput>(rawText);
if (!response.ok) {
const detail = payload.msg1 || rawText.slice(0, 200);
const detail = buildKisErrorDetail({
message: payload.msg1,
msgCode: payload.msg_cd,
extraMessages: payload.msg1 ? [] : [rawText.slice(0, 200)],
});
throw new Error(
detail
? `KIS API 요청 실패 (${response.status}): ${detail}`
@@ -66,7 +71,10 @@ export async function kisGet<TOutput>(
}
if (payload.rt_cd && payload.rt_cd !== "0") {
const detail = [payload.msg1, payload.msg_cd].filter(Boolean).join(" / ");
const detail = buildKisErrorDetail({
message: payload.msg1,
msgCode: payload.msg_cd,
});
throw new Error(detail || "KIS API 비즈니스 오류가 발생했습니다.");
}
@@ -112,7 +120,11 @@ export async function kisPost<TOutput>(
const payload = tryParseKisEnvelope<TOutput>(rawText);
if (!response.ok) {
const detail = payload.msg1 || rawText.slice(0, 200);
const detail = buildKisErrorDetail({
message: payload.msg1,
msgCode: payload.msg_cd,
extraMessages: payload.msg1 ? [] : [rawText.slice(0, 200)],
});
throw new Error(
detail
? `KIS API 요청 실패 (${response.status}): ${detail}`
@@ -121,7 +133,10 @@ export async function kisPost<TOutput>(
}
if (payload.rt_cd && payload.rt_cd !== "0") {
const detail = [payload.msg1, payload.msg_cd].filter(Boolean).join(" / ");
const detail = buildKisErrorDetail({
message: payload.msg1,
msgCode: payload.msg_cd,
});
throw new Error(detail || "KIS API 비즈니스 오류가 발생했습니다.");
}

View File

@@ -0,0 +1,269 @@
/**
* @file lib/kis/dashboard-helpers.ts
* @description 대시보드 계산/포맷 공통 헬퍼 모음
*/
/**
* @description 기준일 기준 N일 범위의 YYYYMMDD 기간을 반환합니다.
* @see lib/kis/dashboard.ts getDomesticOrderHistory/getDomesticTradeJournal 조회 기간 계산
*/
export function getLookbackRangeYmd(lookbackDays: number) {
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - lookbackDays);
return {
startDate: formatYmd(start),
endDate: formatYmd(end),
};
}
/**
* @description Date를 YYYYMMDD 문자열로 변환합니다.
* @see lib/kis/dashboard-helpers.ts getLookbackRangeYmd
*/
export function formatYmd(date: Date) {
const year = String(date.getFullYear());
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}${month}${day}`;
}
/**
* @description 문자열에서 숫자만 추출합니다.
* @see lib/kis/dashboard.ts 주문일자/매매일자/시각 정규화
*/
export function toDigits(value?: string) {
return (value ?? "").replace(/\D/g, "");
}
/**
* @description 주문 시각을 HHMMSS로 정규화합니다.
* @see lib/kis/dashboard.ts getDomesticOrderHistory 정렬/표시 시각 생성
*/
export function normalizeTimeDigits(value?: string) {
const digits = toDigits(value);
if (!digits) return "000000";
return digits.padEnd(6, "0").slice(0, 6);
}
/**
* @description YYYYMMDD를 YYYY-MM-DD로 변환합니다.
* @see lib/kis/dashboard.ts 주문내역/매매일지 날짜 컬럼 표시
*/
export function formatDateLabel(value: string) {
if (value.length !== 8) return "-";
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
}
/**
* @description HHMMSS를 HH:MM:SS로 변환합니다.
* @see lib/kis/dashboard.ts 주문내역 시각 컬럼 표시
*/
export function formatTimeLabel(value: string) {
if (value.length !== 6) return "-";
return `${value.slice(0, 2)}:${value.slice(2, 4)}:${value.slice(4, 6)}`;
}
/**
* @description KIS 매수/매도 코드를 공통 side 값으로 변환합니다.
* @see lib/kis/dashboard.ts getDomesticOrderHistory/getDomesticTradeJournal
*/
export function parseTradeSide(
code?: string,
name?: string,
): "buy" | "sell" | "unknown" {
const normalizedCode = (code ?? "").trim();
const normalizedName = (name ?? "").trim();
if (normalizedCode === "01") return "sell";
if (normalizedCode === "02") return "buy";
if (normalizedName.includes("매도")) return "sell";
if (normalizedName.includes("매수")) return "buy";
return "unknown";
}
/**
* @description 매매일지 요약 기본값을 반환합니다.
* @see lib/kis/dashboard.ts getDomesticDashboardActivity 매매일지 실패 폴백
*/
export function createEmptyJournalSummary() {
return {
totalRealizedProfit: 0,
totalRealizedRate: 0,
totalBuyAmount: 0,
totalSellAmount: 0,
totalFee: 0,
totalTax: 0,
};
}
/**
* @description 문자열 숫자를 number로 변환합니다.
* @see lib/kis/dashboard.ts 잔고/지수 필드 공통 파싱
*/
export 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;
}
/**
* @description 문자열 숫자를 number로 변환하되 0도 유효값으로 유지합니다.
* @see lib/kis/dashboard.ts 요약값 폴백 순서 계산
*/
export 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;
}
/**
* @description output 계열 데이터를 배열 형태로 변환합니다.
* @see lib/kis/dashboard.ts 잔고 output1/output2 파싱
*/
export function parseRows<T>(value: unknown): T[] {
if (Array.isArray(value)) return value as T[];
if (value && typeof value === "object") return [value as T];
return [];
}
/**
* @description output 계열 데이터의 첫 행을 반환합니다.
* @see lib/kis/dashboard.ts 잔고 요약(output2) 파싱
*/
export function parseFirstRow<T>(value: unknown) {
const rows = parseRows<T>(value);
return rows[0];
}
/**
* @description 지수 output을 단일 레코드로 정규화합니다.
* @see lib/kis/dashboard.ts getDomesticDashboardIndices
*/
export function parseIndexRow<T extends object>(
output: unknown,
): T {
if (Array.isArray(output) && output[0] && typeof output[0] === "object") {
return output[0] as T;
}
if (output && typeof output === "object") {
return output as T;
}
return {} as T;
}
/**
* @description KIS 부호 코드(1/2 상승, 4/5 하락)를 실제 부호로 반영합니다.
* @see lib/kis/dashboard.ts getDomesticDashboardIndices
*/
export 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;
}
/**
* @description undefined가 아닌 첫 값을 반환합니다.
* @see lib/kis/dashboard.ts 요약 수익률/손익 폴백 계산
*/
export function firstDefinedNumber(...values: Array<number | undefined>) {
return values.find((value) => value !== undefined) ?? 0;
}
/**
* @description 숫자 배열 합계를 계산합니다.
* @see lib/kis/dashboard.ts 보유종목 합계 계산
*/
export function sumNumbers(values: number[]) {
return values.reduce((total, value) => total + value, 0);
}
/**
* @description 총자산 대비 손익률을 계산합니다.
* @see lib/kis/dashboard.ts 요약 수익률 폴백 계산
*/
export function calcProfitRate(profit: number, totalAmount: number) {
if (totalAmount <= 0) return 0;
const baseAmount = totalAmount - profit;
if (baseAmount <= 0) return 0;
return (profit / baseAmount) * 100;
}
/**
* @description 매입금액 대비 손익률을 계산합니다.
* @see lib/kis/dashboard.ts getDomesticDashboardBalance 현재 손익률 산출
*/
export function calcProfitRateByPurchase(profit: number, purchaseAmount: number) {
if (purchaseAmount <= 0) return 0;
return (profit / purchaseAmount) * 100;
}
/**
* @description 총자산과 평가금액 기준으로 일관된 현금성 자산(예수금)을 계산합니다.
* @remarks UI 흐름: 대시보드 API 응답 생성 -> 총자산/평가금/예수금 카드 반영
* @see lib/kis/dashboard.ts getDomesticDashboardBalance
*/
export function resolveCashBalance(params: {
apiReportedTotalAmount: number;
apiReportedNetAssetAmount: number;
evaluationAmount: number;
cashCandidates: Array<number | undefined>;
}) {
const {
apiReportedTotalAmount,
apiReportedNetAssetAmount,
evaluationAmount,
cashCandidates,
} = params;
const referenceTotalAmount = pickPreferredAmount(
apiReportedNetAssetAmount,
apiReportedTotalAmount,
);
const candidateCash = pickPreferredAmount(...cashCandidates);
const derivedCash =
referenceTotalAmount > 0
? Math.max(referenceTotalAmount - evaluationAmount, 0)
: undefined;
if (derivedCash === undefined) return candidateCash;
const recomposedWithCandidate = candidateCash + evaluationAmount;
const mismatchWithApi = Math.abs(
recomposedWithCandidate - referenceTotalAmount,
);
if (mismatchWithApi >= 1) {
return derivedCash;
}
return candidateCash;
}
/**
* @description 금액 후보 중 양수 값을 우선 선택합니다.
* @see lib/kis/dashboard.ts getDomesticDashboardBalance 금액 필드 폴백 계산
*/
export function pickPreferredAmount(...values: Array<number | undefined>) {
const positive = values.find(
(value): value is number => value !== undefined && value > 0,
);
if (positive !== undefined) return positive;
return firstDefinedNumber(...values);
}
/**
* @description 숫자 후보 중 0이 아닌 값을 우선 선택합니다.
* @see lib/kis/dashboard.ts getDomesticDashboardBalance 손익/수익률 폴백 계산
*/
export function pickNonZeroNumber(...values: Array<number | undefined>) {
const nonZero = values.find(
(value): value is number => value !== undefined && value !== 0,
);
if (nonZero !== undefined) return nonZero;
return firstDefinedNumber(...values);
}

View File

@@ -8,6 +8,28 @@ 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;
@@ -478,7 +500,7 @@ export async function getDomesticDashboardIndices(
credentials,
);
const row = parseIndexRow(response.output);
const row = parseIndexRow<KisIndexOutputRow>(response.output);
const rawChange = toNumber(row.bstp_nmix_prdy_vrss);
const rawChangeRate = toNumber(row.bstp_nmix_prdy_ctrt);
@@ -780,309 +802,3 @@ async function getDomesticTradeJournal(
summary,
};
}
/**
* 기준일 기준 N일 범위의 YYYYMMDD 기간을 반환합니다.
* @param lookbackDays 과거 조회 일수
* @returns 시작/종료 일자
* @see lib/kis/dashboard.ts getDomesticOrderHistory/getDomesticTradeJournal 조회 기간 계산
*/
function getLookbackRangeYmd(lookbackDays: number) {
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - lookbackDays);
return {
startDate: formatYmd(start),
endDate: formatYmd(end),
};
}
/**
* Date를 YYYYMMDD 문자열로 변환합니다.
* @param date 기준 일자
* @returns YYYYMMDD
* @see lib/kis/dashboard.ts getLookbackRangeYmd
*/
function formatYmd(date: Date) {
const year = String(date.getFullYear());
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}${month}${day}`;
}
/**
* 문자열에서 숫자만 추출합니다.
* @param value 원본 문자열
* @returns 숫자 문자열
* @see lib/kis/dashboard.ts 주문일자/매매일자/시각 정규화
*/
function toDigits(value?: string) {
return (value ?? "").replace(/\D/g, "");
}
/**
* 주문 시각을 HHMMSS로 정규화합니다.
* @param value 시각 문자열
* @returns 6자리 시각 문자열
* @see lib/kis/dashboard.ts getDomesticOrderHistory 정렬/표시 시각 생성
*/
function normalizeTimeDigits(value?: string) {
const digits = toDigits(value);
if (!digits) return "000000";
return digits.padEnd(6, "0").slice(0, 6);
}
/**
* YYYYMMDD를 YYYY-MM-DD로 변환합니다.
* @param value 날짜 문자열
* @returns YYYY-MM-DD 또는 "-"
* @see lib/kis/dashboard.ts 주문내역/매매일지 날짜 컬럼 표시
*/
function formatDateLabel(value: string) {
if (value.length !== 8) return "-";
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
}
/**
* HHMMSS를 HH:MM:SS로 변환합니다.
* @param value 시각 문자열
* @returns HH:MM:SS 또는 "-"
* @see lib/kis/dashboard.ts 주문내역 시각 컬럼 표시
*/
function formatTimeLabel(value: string) {
if (value.length !== 6) return "-";
return `${value.slice(0, 2)}:${value.slice(2, 4)}:${value.slice(4, 6)}`;
}
/**
* KIS 매수/매도 코드를 공통 side 값으로 변환합니다.
* @param code 매수매도구분코드
* @param name 매수매도구분명 또는 매매구분명
* @returns buy/sell/unknown
* @see lib/kis/dashboard.ts getDomesticOrderHistory/getDomesticTradeJournal
*/
function parseTradeSide(code?: string, name?: string): "buy" | "sell" | "unknown" {
const normalizedCode = (code ?? "").trim();
const normalizedName = (name ?? "").trim();
if (normalizedCode === "01") return "sell";
if (normalizedCode === "02") return "buy";
if (normalizedName.includes("매도")) return "sell";
if (normalizedName.includes("매수")) return "buy";
return "unknown";
}
/**
* 매매일지 요약 기본값을 반환합니다.
* @returns 0으로 채운 요약 객체
* @see lib/kis/dashboard.ts getDomesticDashboardActivity 매매일지 실패 폴백
*/
function createEmptyJournalSummary(): DomesticTradeJournalSummary {
return {
totalRealizedProfit: 0,
totalRealizedRate: 0,
totalBuyAmount: 0,
totalSellAmount: 0,
totalFee: 0,
totalTax: 0,
};
}
/**
* 문자열 숫자를 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;
}
/**
* 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;
}
/**
* 매입금액 대비 손익률을 계산합니다.
* @param profit 손익 금액
* @param purchaseAmount 매입금액
* @returns 손익률(%)
* @see lib/kis/dashboard.ts getDomesticDashboardBalance 현재 손익률 산출
*/
function calcProfitRateByPurchase(profit: number, purchaseAmount: number) {
if (purchaseAmount <= 0) return 0;
return (profit / purchaseAmount) * 100;
}
/**
* 총자산과 평가금액 기준으로 일관된 현금성 자산(예수금)을 계산합니다.
* @param params 계산 파라미터
* @returns 현금성 자산 금액
* @remarks UI 흐름: 대시보드 API 응답 생성 -> 총자산/평가금/예수금 카드 반영
* @see lib/kis/dashboard.ts getDomesticDashboardBalance
*/
function resolveCashBalance(params: {
apiReportedTotalAmount: number;
apiReportedNetAssetAmount: number;
evaluationAmount: number;
cashCandidates: Array<number | undefined>;
}) {
const {
apiReportedTotalAmount,
apiReportedNetAssetAmount,
evaluationAmount,
cashCandidates,
} = params;
const referenceTotalAmount = pickPreferredAmount(
apiReportedNetAssetAmount,
apiReportedTotalAmount,
);
const candidateCash = pickPreferredAmount(...cashCandidates);
const derivedCash =
referenceTotalAmount > 0
? Math.max(referenceTotalAmount - evaluationAmount, 0)
: undefined;
if (derivedCash === undefined) return candidateCash;
// 후보 예수금 + 평가금이 기준 총자산(순자산 우선)과 크게 다르면 역산값을 사용합니다.
const recomposedWithCandidate = candidateCash + evaluationAmount;
const mismatchWithApi = Math.abs(
recomposedWithCandidate - referenceTotalAmount,
);
if (mismatchWithApi >= 1) {
return derivedCash;
}
return candidateCash;
}
/**
* 금액 후보 중 양수 값을 우선 선택합니다.
* @param values 금액 후보
* @returns 양수 우선 금액
* @see lib/kis/dashboard.ts getDomesticDashboardBalance 금액 필드 폴백 계산
*/
function pickPreferredAmount(...values: Array<number | undefined>) {
const positive = values.find(
(value): value is number => value !== undefined && value > 0,
);
if (positive !== undefined) return positive;
return firstDefinedNumber(...values);
}
/**
* 숫자 후보 중 0이 아닌 값을 우선 선택합니다.
* @param values 숫자 후보
* @returns 0이 아닌 값 우선 결과
* @see lib/kis/dashboard.ts getDomesticDashboardBalance 손익/수익률 폴백 계산
*/
function pickNonZeroNumber(...values: Array<number | undefined>) {
const nonZero = values.find(
(value): value is number => value !== undefined && value !== 0,
);
if (nonZero !== undefined) return nonZero;
return firstDefinedNumber(...values);
}

349
lib/kis/domestic-helpers.ts Normal file
View File

@@ -0,0 +1,349 @@
import type {
DashboardChartTimeframe,
StockCandlePoint,
} from "@/features/trade/types/trade.types";
type DomesticChartRow = Record<string, unknown>;
type OhlcvTuple = {
open: number;
high: number;
low: number;
close: number;
volume: number;
};
/**
* @description 문자열 숫자를 안전하게 number로 변환합니다.
* @see lib/kis/domestic.ts 국내 시세/차트 숫자 필드 파싱
*/
export function toNumber(value?: string) {
if (!value) return 0;
const normalized = value.replace(/,/g, "").trim();
if (!normalized) return 0;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : 0;
}
/**
* @description 숫자 문자열을 optional number로 변환합니다.
* @see lib/kis/domestic.ts 현재가 소스 선택 시 값 존재 여부 판단
*/
export function toOptionalNumber(value?: string) {
if (!value) return undefined;
const normalized = value.replace(/,/g, "").trim();
if (!normalized) return undefined;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : undefined;
}
/**
* @description KIS 부호 코드를 실제 부호로 반영합니다.
* @see lib/kis/domestic.ts 지수/시세 변동값 정규화
*/
export 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;
}
/**
* @description 시장명을 코스피/코스닥으로 정규화합니다.
* @see lib/kis/domestic.ts 종목 overview 응답 market 값 결정
*/
export function resolveMarket(...values: Array<string | undefined>) {
const merged = values.filter(Boolean).join(" ");
if (merged.includes("코스닥") || merged.toUpperCase().includes("KOSDAQ")) {
return "KOSDAQ" as const;
}
return "KOSPI" as const;
}
/**
* @description 일봉 output을 차트 공통 candle 포맷으로 변환합니다.
* @see lib/kis/domestic.ts getDomesticOverview candles 생성
*/
export function toCandles(
rows: Array<{
stck_bsop_date?: string;
stck_oprc?: string;
stck_hgpr?: string;
stck_lwpr?: string;
stck_clpr?: string;
acml_vol?: string;
}>,
currentPrice: number,
): StockCandlePoint[] {
const parsed = rows
.map((row) => ({
date: row.stck_bsop_date ?? "",
open: toNumber(row.stck_oprc),
high: toNumber(row.stck_hgpr),
low: toNumber(row.stck_lwpr),
close: toNumber(row.stck_clpr),
volume: toNumber(row.acml_vol),
}))
.filter((item) => item.date.length === 8 && item.close > 0)
.sort((a, b) => a.date.localeCompare(b.date))
.slice(-80)
.map((item) => ({
time: formatDate(item.date),
price: item.close,
open: item.open > 0 ? item.open : item.close,
high: item.high > 0 ? item.high : item.close,
low: item.low > 0 ? item.low : item.close,
close: item.close,
volume: item.volume,
}));
if (parsed.length > 0) return parsed;
const now = new Date();
const mm = `${now.getMonth() + 1}`.padStart(2, "0");
const dd = `${now.getDate()}`.padStart(2, "0");
const safePrice = Math.max(currentPrice, 0);
return [
{
time: `${mm}/${dd}`,
timestamp: Math.floor(now.getTime() / 1000),
price: safePrice,
open: safePrice,
high: safePrice,
low: safePrice,
close: safePrice,
volume: 0,
},
];
}
export function formatDate(date: string) {
return `${date.slice(4, 6)}/${date.slice(6, 8)}`;
}
export function firstDefinedNumber(...values: Array<number | undefined>) {
return values.find((value) => value !== undefined);
}
export function firstDefinedString(...values: Array<string | undefined>) {
return values.find((value) => Boolean(value));
}
/**
* @description 장중/시간외에 따라 현재가 우선 소스를 결정합니다.
* @see lib/kis/domestic.ts getDomesticOverview priceSource 계산
*/
export function resolveCurrentPriceSource(
marketPhase: "regular" | "afterHours",
overtime: { ovtm_untp_prpr?: string },
ccnl: { stck_prpr?: string },
quote: { stck_prpr?: string },
): "inquire-price" | "inquire-ccnl" | "inquire-overtime-price" {
const hasOvertimePrice =
toOptionalNumber(overtime.ovtm_untp_prpr) !== undefined;
const hasCcnlPrice = toOptionalNumber(ccnl.stck_prpr) !== undefined;
const hasQuotePrice = toOptionalNumber(quote.stck_prpr) !== undefined;
if (marketPhase === "afterHours") {
if (hasOvertimePrice) return "inquire-overtime-price";
if (hasCcnlPrice) return "inquire-ccnl";
return "inquire-price";
}
if (hasCcnlPrice) return "inquire-ccnl";
if (hasQuotePrice) return "inquire-price";
return "inquire-price";
}
export function firstPositive(...values: number[]) {
return values.find((value) => value > 0) ?? 0;
}
/**
* @description KIS 차트 응답에서 output2 배열을 안전하게 추출합니다.
* @see lib/kis/domestic.ts getDomesticDailyTimeChart/getDomesticChart
*/
export function parseOutput2Rows(envelope: {
output2?: unknown;
output1?: unknown;
output?: unknown;
}) {
if (Array.isArray(envelope.output2)) return envelope.output2 as DomesticChartRow[];
if (Array.isArray(envelope.output)) return envelope.output as DomesticChartRow[];
for (const key of ["output2", "output", "output1"] as const) {
const value = envelope[key];
if (value && typeof value === "object" && !Array.isArray(value)) {
return [value as DomesticChartRow];
}
}
return [] as DomesticChartRow[];
}
export function readRowString(row: DomesticChartRow, ...keys: string[]) {
for (const key of keys) {
const value = row[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return "";
}
export function readOhlcv(row: DomesticChartRow): OhlcvTuple | null {
const close = toNumber(
readRowString(row, "stck_clpr", "STCK_CLPR") ||
readRowString(row, "stck_prpr", "STCK_PRPR"),
);
if (close <= 0) return null;
const open = toNumber(readRowString(row, "stck_oprc", "STCK_OPRC")) || close;
const high =
toNumber(readRowString(row, "stck_hgpr", "STCK_HGPR")) ||
Math.max(open, close);
const low =
toNumber(readRowString(row, "stck_lwpr", "STCK_LWPR")) ||
Math.min(open, close);
const volume = toNumber(
readRowString(row, "acml_vol", "ACML_VOL") ||
readRowString(row, "cntg_vol", "CNTG_VOL"),
);
return { open, high, low, close, volume };
}
export function parseDayCandleRow(row: DomesticChartRow): StockCandlePoint | null {
const date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE");
if (!/^\d{8}$/.test(date)) return null;
const ohlcv = readOhlcv(row);
if (!ohlcv) return null;
return {
time: formatDate(date),
timestamp: toKstTimestamp(date, "090000"),
price: ohlcv.close,
...ohlcv,
};
}
export function parseMinuteCandleRow(
row: DomesticChartRow,
minuteBucket: number,
): StockCandlePoint | null {
let date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE");
const rawTime = readRowString(row, "stck_cntg_hour", "STCK_CNTG_HOUR");
const time = /^\d{6}$/.test(rawTime)
? rawTime
: /^\d{4}$/.test(rawTime)
? `${rawTime}00`
: "";
if (!/^\d{8}$/.test(date)) date = nowYmdInKst();
if (!/^\d{8}$/.test(date) || !/^\d{6}$/.test(time)) return null;
const ohlcv = readOhlcv(row);
if (!ohlcv) return null;
const bucketed = alignTimeToMinuteBucket(time, minuteBucket);
return {
time: `${bucketed.slice(0, 2)}:${bucketed.slice(2, 4)}`,
timestamp: toKstTimestamp(date, bucketed),
price: ohlcv.close,
...ohlcv,
};
}
export function mergeCandlesByTimestamp(rows: StockCandlePoint[]) {
const map = new Map<number, StockCandlePoint>();
for (const row of rows) {
if (!row.timestamp) continue;
const prev = map.get(row.timestamp);
if (!prev) {
map.set(row.timestamp, row);
continue;
}
map.set(row.timestamp, {
...prev,
price: row.close ?? row.price,
close: row.close ?? row.price,
high: Math.max(prev.high ?? prev.price, row.high ?? row.price),
low: Math.min(prev.low ?? prev.price, row.low ?? row.price),
volume: (prev.volume ?? 0) + (row.volume ?? 0),
});
}
return Array.from(map.values()).sort(
(a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
);
}
export function alignTimeToMinuteBucket(hhmmss: string, bucket: number) {
if (/^\d{4}$/.test(hhmmss)) hhmmss = `${hhmmss}00`;
if (bucket <= 1) return hhmmss;
const hh = Number(hhmmss.slice(0, 2));
const mm = Number(hhmmss.slice(2, 4));
const aligned = Math.floor(mm / bucket) * bucket;
return `${hh.toString().padStart(2, "0")}${aligned.toString().padStart(2, "0")}00`;
}
export function toKstTimestamp(yyyymmdd: string, hhmmss: string) {
const y = Number(yyyymmdd.slice(0, 4));
const mo = Number(yyyymmdd.slice(4, 6));
const d = Number(yyyymmdd.slice(6, 8));
const hh = Number(hhmmss.slice(0, 2));
const mm = Number(hhmmss.slice(2, 4));
const ss = Number(hhmmss.slice(4, 6));
return Math.floor(Date.UTC(y, mo - 1, d, hh - 9, mm, ss) / 1000);
}
export function shiftYmd(ymd: string, days: number) {
const utc = new Date(
Date.UTC(
Number(ymd.slice(0, 4)),
Number(ymd.slice(4, 6)) - 1,
Number(ymd.slice(6, 8)),
),
);
utc.setUTCDate(utc.getUTCDate() + days);
return toYmd(utc);
}
export function nowYmdInKst() {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const map = new Map(parts.map((part) => [part.type, part.value]));
return `${map.get("year")}${map.get("month")}${map.get("day")}`;
}
export function nowHmsInKst() {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Seoul",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(new Date());
const map = new Map(parts.map((part) => [part.type, part.value]));
return `${map.get("hour")}${map.get("minute")}${map.get("second")}`;
}
export function minutesForTimeframe(tf: DashboardChartTimeframe) {
if (tf === "30m") return 30;
if (tf === "1h") return 60;
return 1;
}
export function subOneMinute(hhmmss: string) {
const hh = Number(hhmmss.slice(0, 2));
const mm = Number(hhmmss.slice(2, 4));
let totalMin = hh * 60 + mm - 1;
if (totalMin < 0) totalMin = 0;
const hour = Math.floor(totalMin / 60);
const minute = totalMin % 60;
return `${String(hour).padStart(2, "0")}${String(minute).padStart(2, "0")}00`;
}
function toYmd(date: Date) {
return `${date.getUTCFullYear()}${`${date.getUTCMonth() + 1}`.padStart(2, "0")}${`${date.getUTCDate()}`.padStart(2, "0")}`;
}

View File

@@ -10,6 +10,27 @@ import {
resolveDomesticKisSession,
shouldUseOvertimeOrderBookApi,
} from "@/lib/kis/domestic-market-session";
import {
firstDefinedNumber,
firstDefinedString,
firstPositive,
mergeCandlesByTimestamp,
minutesForTimeframe,
normalizeSignedValue,
nowHmsInKst,
nowYmdInKst,
parseDayCandleRow,
parseMinuteCandleRow,
parseOutput2Rows,
readRowString,
resolveCurrentPriceSource,
resolveMarket,
shiftYmd,
subOneMinute,
toCandles,
toNumber,
toOptionalNumber,
} from "@/lib/kis/domestic-helpers";
/**
* @file lib/kis/domestic.ts
@@ -59,18 +80,6 @@ interface KisDomesticDailyPriceOutput {
stck_clpr?: string;
acml_vol?: string;
}
interface KisDomesticItemChartRow {
stck_bsop_date?: string;
stck_cntg_hour?: string;
stck_oprc?: string;
stck_hgpr?: string;
stck_lwpr?: string;
stck_clpr?: string;
stck_prpr?: string;
cntg_vol?: string;
acml_vol?: string;
}
export interface KisDomesticOrderBookOutput {
stck_prpr?: string;
total_askp_rsqn?: string;
@@ -394,87 +403,6 @@ export async function getDomesticOverview(
};
}
function toNumber(value?: string) {
if (!value) return 0;
const normalized = value.replace(/,/g, "").trim();
if (!normalized) return 0;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : 0;
}
function toOptionalNumber(value?: string) {
if (!value) return undefined;
const normalized = value.replace(/,/g, "").trim();
if (!normalized) return undefined;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : undefined;
}
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;
}
function resolveMarket(...values: Array<string | undefined>) {
const merged = values.filter(Boolean).join(" ");
if (merged.includes("코스닥") || merged.toUpperCase().includes("KOSDAQ"))
return "KOSDAQ" as const;
return "KOSPI" as const;
}
function toCandles(
rows: KisDomesticDailyPriceOutput[],
currentPrice: number,
): StockCandlePoint[] {
const parsed = rows
.map((row) => ({
date: row.stck_bsop_date ?? "",
open: toNumber(row.stck_oprc),
high: toNumber(row.stck_hgpr),
low: toNumber(row.stck_lwpr),
close: toNumber(row.stck_clpr),
volume: toNumber(row.acml_vol),
}))
.filter((item) => item.date.length === 8 && item.close > 0)
.sort((a, b) => a.date.localeCompare(b.date))
.slice(-80)
.map((item) => ({
time: formatDate(item.date),
price: item.close,
open: item.open > 0 ? item.open : item.close,
high: item.high > 0 ? item.high : item.close,
low: item.low > 0 ? item.low : item.close,
close: item.close,
volume: item.volume,
}));
if (parsed.length > 0) return parsed;
const now = new Date();
const mm = `${now.getMonth() + 1}`.padStart(2, "0");
const dd = `${now.getDate()}`.padStart(2, "0");
const safePrice = Math.max(currentPrice, 0);
return [
{
time: `${mm}/${dd}`,
timestamp: Math.floor(now.getTime() / 1000),
price: safePrice,
open: safePrice,
high: safePrice,
low: safePrice,
close: safePrice,
volume: 0,
},
];
}
function formatDate(date: string) {
return `${date.slice(4, 6)}/${date.slice(6, 8)}`;
}
function getDomesticMarketPhaseInKst(
now = new Date(),
sessionOverride?: string | null,
@@ -484,231 +412,16 @@ function getDomesticMarketPhaseInKst(
);
}
function firstDefinedNumber(...values: Array<number | undefined>) {
return values.find((value) => value !== undefined);
}
function firstDefinedString(...values: Array<string | undefined>) {
return values.find((value) => Boolean(value));
}
function resolveCurrentPriceSource(
marketPhase: DomesticMarketPhase,
overtime: KisDomesticOvertimePriceOutput,
ccnl: KisDomesticCcnlOutput,
quote: KisDomesticQuoteOutput,
): DomesticPriceSource {
const hasOvertimePrice =
toOptionalNumber(overtime.ovtm_untp_prpr) !== undefined;
const hasCcnlPrice = toOptionalNumber(ccnl.stck_prpr) !== undefined;
const hasQuotePrice = toOptionalNumber(quote.stck_prpr) !== undefined;
if (marketPhase === "afterHours") {
if (hasOvertimePrice) return "inquire-overtime-price";
if (hasCcnlPrice) return "inquire-ccnl";
return "inquire-price";
}
if (hasCcnlPrice) return "inquire-ccnl";
if (hasQuotePrice) return "inquire-price";
return "inquire-price";
}
function resolvePriceMarketDivCode() {
return "J";
}
function firstPositive(...values: number[]) {
return values.find((value) => value > 0) ?? 0;
}
export interface DomesticChartResult {
candles: StockCandlePoint[];
nextCursor: string | null;
hasMore: boolean;
}
// ─── KIS output2 배열 추출 ─────────────────────────────────
function parseOutput2Rows(envelope: {
output2?: unknown;
output1?: unknown;
output?: unknown;
}) {
if (Array.isArray(envelope.output2))
return envelope.output2 as KisDomesticItemChartRow[];
if (Array.isArray(envelope.output))
return envelope.output as KisDomesticItemChartRow[];
for (const key of ["output2", "output", "output1"] as const) {
const v = envelope[key];
if (v && typeof v === "object" && !Array.isArray(v))
return [v as KisDomesticItemChartRow];
}
return [];
}
// ─── Row → StockCandlePoint 변환 ───────────────────────────
function readRowString(row: KisDomesticItemChartRow, ...keys: string[]) {
const record = row as Record<string, unknown>;
for (const key of keys) {
const v = record[key];
if (typeof v === "string" && v.trim()) return v.trim();
}
return "";
}
function readOhlcv(row: KisDomesticItemChartRow) {
const close = toNumber(
readRowString(row, "stck_clpr", "STCK_CLPR") ||
readRowString(row, "stck_prpr", "STCK_PRPR"),
);
if (close <= 0) return null;
const open = toNumber(readRowString(row, "stck_oprc", "STCK_OPRC")) || close;
const high =
toNumber(readRowString(row, "stck_hgpr", "STCK_HGPR")) ||
Math.max(open, close);
const low =
toNumber(readRowString(row, "stck_lwpr", "STCK_LWPR")) ||
Math.min(open, close);
const volume = toNumber(
readRowString(row, "acml_vol", "ACML_VOL") ||
readRowString(row, "cntg_vol", "CNTG_VOL"),
);
return { open, high, low, close, volume };
}
function parseDayCandleRow(
row: KisDomesticItemChartRow,
): StockCandlePoint | null {
const date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE");
if (!/^\d{8}$/.test(date)) return null;
const ohlcv = readOhlcv(row);
if (!ohlcv) return null;
return {
time: formatDate(date),
timestamp: toKstTimestamp(date, "090000"),
price: ohlcv.close,
...ohlcv,
};
}
function parseMinuteCandleRow(
row: KisDomesticItemChartRow,
minuteBucket: number,
): StockCandlePoint | null {
let date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE");
const rawTime = readRowString(row, "stck_cntg_hour", "STCK_CNTG_HOUR");
const time = /^\d{6}$/.test(rawTime)
? rawTime
: /^\d{4}$/.test(rawTime)
? `${rawTime}00`
: "";
if (!/^\d{8}$/.test(date)) date = nowYmdInKst(); // 당일 분봉은 날짜가 빠져있을 수 있음
if (!/^\d{8}$/.test(date) || !/^\d{6}$/.test(time)) return null;
const ohlcv = readOhlcv(row);
if (!ohlcv) return null;
const bucketed = alignTimeToMinuteBucket(time, minuteBucket);
return {
time: `${bucketed.slice(0, 2)}:${bucketed.slice(2, 4)}`,
timestamp: toKstTimestamp(date, bucketed),
price: ohlcv.close,
...ohlcv,
};
}
// ─── 같은 타임스탬프 봉 병합 ───────────────────────────────
function mergeCandlesByTimestamp(rows: StockCandlePoint[]) {
const map = new Map<number, StockCandlePoint>();
for (const row of rows) {
if (!row.timestamp) continue;
const prev = map.get(row.timestamp);
if (!prev) {
map.set(row.timestamp, row);
continue;
}
map.set(row.timestamp, {
...prev,
price: row.close ?? row.price,
close: row.close ?? row.price,
high: Math.max(prev.high ?? prev.price, row.high ?? row.price),
low: Math.min(prev.low ?? prev.price, row.low ?? row.price),
volume: (prev.volume ?? 0) + (row.volume ?? 0),
});
}
return Array.from(map.values()).sort(
(a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
);
}
// ─── 시간 유틸 ─────────────────────────────────────────────
function alignTimeToMinuteBucket(hhmmss: string, bucket: number) {
if (/^\d{4}$/.test(hhmmss)) hhmmss = `${hhmmss}00`;
if (bucket <= 1) return hhmmss;
const hh = Number(hhmmss.slice(0, 2));
const mm = Number(hhmmss.slice(2, 4));
const aligned = Math.floor(mm / bucket) * bucket;
return `${hh.toString().padStart(2, "0")}${aligned.toString().padStart(2, "0")}00`;
}
function toKstTimestamp(yyyymmdd: string, hhmmss: string) {
const y = Number(yyyymmdd.slice(0, 4));
const mo = Number(yyyymmdd.slice(4, 6));
const d = Number(yyyymmdd.slice(6, 8));
const hh = Number(hhmmss.slice(0, 2));
const mm = Number(hhmmss.slice(2, 4));
const ss = Number(hhmmss.slice(4, 6));
return Math.floor(Date.UTC(y, mo - 1, d, hh - 9, mm, ss) / 1000);
}
function toYmd(date: Date) {
return `${date.getUTCFullYear()}${`${date.getUTCMonth() + 1}`.padStart(2, "0")}${`${date.getUTCDate()}`.padStart(2, "0")}`;
}
function shiftYmd(ymd: string, days: number) {
const utc = new Date(
Date.UTC(
Number(ymd.slice(0, 4)),
Number(ymd.slice(4, 6)) - 1,
Number(ymd.slice(6, 8)),
),
);
utc.setUTCDate(utc.getUTCDate() + days);
return toYmd(utc);
}
function nowYmdInKst() {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const m = new Map(parts.map((p) => [p.type, p.value]));
return `${m.get("year")}${m.get("month")}${m.get("day")}`;
}
function nowHmsInKst() {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Seoul",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(new Date());
const m = new Map(parts.map((p) => [p.type, p.value]));
return `${m.get("hour")}${m.get("minute")}${m.get("second")}`;
}
function minutesForTimeframe(tf: DashboardChartTimeframe) {
if (tf === "30m") return 30;
if (tf === "1h") return 60;
return 1;
}
/**
* 국내주식 주식일별분봉조회 (과거 분봉)
* @param symbol 종목코드
@@ -794,7 +507,7 @@ export async function getDomesticChart(
// ── 분봉 (1m / 30m / 1h) ──
const minuteBucket = minutesForTimeframe(timeframe);
let rawRows: KisDomesticItemChartRow[] = [];
let rawRows: Array<Record<string, unknown>> = [];
let nextCursor: string | null = null;
// Case A: 과거 데이터 조회 (커서 존재)
@@ -896,14 +609,3 @@ export async function getDomesticChart(
return { candles, hasMore: Boolean(nextCursor), nextCursor };
}
function subOneMinute(hhmmss: string) {
const hh = Number(hhmmss.slice(0, 2));
const mm = Number(hhmmss.slice(2, 4));
let totalMin = hh * 60 + mm - 1;
if (totalMin < 0) totalMin = 0;
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return `${String(h).padStart(2, '0')}${String(m).padStart(2, '0')}00`;
}

188
lib/kis/error-codes.ts Normal file
View File

@@ -0,0 +1,188 @@
/**
* @file lib/kis/error-codes.ts
* @description KIS FAQ 오류코드(msg_cd) 문구를 공통으로 해석하는 유틸입니다.
* @see https://apiportal.koreainvestment.com/faq-error-code 한국투자증권 공식 오류코드 기준
*/
export const KIS_ERROR_CODE_REFERENCE_URL =
"https://apiportal.koreainvestment.com/faq-error-code";
const KIS_ERROR_CODE_MESSAGE_MAP = {
EGW00001: "일시적 오류가 발생했습니다.",
EGW00002: "서버 에러가 발생했습니다.",
EGW00003: "접근이 거부되었습니다.",
EGW00004: "권한을 부여받지 않은 고객입니다.",
EGW00101: "유효하지 않은 요청입니다.",
EGW00102: "AppKey는 필수입니다.",
EGW00103: "유효하지 않은 AppKey입니다.",
EGW00104: "AppSecret은 필수입니다.",
EGW00105: "유효하지 않은 AppSecret입니다.",
EGW00106: "redirect_uri는 필수입니다.",
EGW00107: "유효하지 않은 redirect_uri입니다.",
EGW00108: "유효하지 않은 서비스구분(service)입니다.",
EGW00109: "scope는 필수입니다.",
EGW00110: "유효하지 않은 scope 입니다.",
EGW00111: "유효하지 않은 state 입니다.",
EGW00112: "유효하지 않은 grant 입니다.",
EGW00113: "응답구분(response_type)은 필수입니다.",
EGW00114: "지원하지 않는 응답구분(response_type)입니다.",
EGW00115: "권한부여 타입(grant_type)은 필수입니다.",
EGW00116: "지원하지 않는 권한부여 타입(grant_type)입니다.",
EGW00117: "지원하지 않는 토큰 타입(token_type)입니다.",
EGW00118: "유효하지 않은 code 입니다.",
EGW00119: "code를 찾을 수 없습니다.",
EGW00120: "기간이 만료된 code 입니다.",
EGW00121: "유효하지 않은 token 입니다.",
EGW00122: "token을 찾을 수 없습니다.",
EGW00123: "기간이 만료된 token 입니다.",
EGW00124: "유효하지 않은 session_key 입니다.",
EGW00125: "session_key를 찾을 수 없습니다.",
EGW00126: "기간이 만료된 session_key 입니다.",
EGW00127: "제휴사번호(corpno)는 필수입니다.",
EGW00128: "계좌번호(acctno)는 필수입니다.",
EGW00129: "HTS_ID는 필수입니다.",
EGW00130: "유효하지 않은 유저(user)입니다.",
EGW00131: "유효하지 않은 hashkey입니다.",
EGW00132: "Content-Type이 유효하지 않습니다.",
EGW00201: "초당 거래건수를 초과하였습니다.",
EGW00202: "GW라우팅 중 오류가 발생했습니다.",
EGW00203: "OPS라우팅 중 오류가 발생했습니다.",
EGW00204: "Internal Gateway 인스턴스를 잘못 입력했습니다.",
EGW00205: "credentials_type이 유효하지 않습니다.(Bearer)",
EGW00206: "API 사용 권한이 없습니다.",
EGW00207: "IP 주소가 없거나 유효하지 않습니다.",
EGW00208: "고객유형(custtype)이 유효하지 않습니다.",
EGW00209: "일련번호(seq_no)가 유효하지 않습니다.",
EGW00210: "법인고객의 경우 모의투자를 이용할 수 없습니다.",
EGW00211: "고객명(personalname)은 필수 입니다.",
EGW00212: "휴대전화번호(personalphone)는 필수 입니다.",
EGW00213: "제휴사명(corpname)은 필수 입니다. / 모의투자 tr이 아닙니다.",
EGW00300: "Gateway 라우팅 오류가 발생했습니다.",
EGW00301: "연결 시간이 초과되었습니다. 직전 거래를 반드시 확인하세요.",
EGW00302: "거래시간이 초과되었습니다. 직전 거래를 반드시 확인하세요.",
EGW00303: "법인고객에게 허용되지 않은 IP접근입니다.",
EGW00304:
"고객식별키(법인 personalSeckey, 개인 appSecret)가 유효하지 않습니다.",
OPSQ0001: "호출 전처리 오류 입니다.",
OPSQ0002: "없는 서비스 코드 입니다.",
OPSQ0003: "호출 오류 입니다.",
OPSQ0004: "호출 후처리 오류 입니다.",
OPSQ0005: "호출 후처리 오류 입니다.",
OPSQ0006: "호출 후처리 오류 입니다.",
OPSQ0007: "호출 후처리(헤더설정) 오류 입니다.",
OPSQ0008: "호출 후처리(MCI전송) 오류 입니다.",
OPSQ0009: "호출 후처리(MCI수신) 오류 입니다.",
OPSQ0010: "호출 결과처리(리소스 부족) 오류 입니다.",
OPSQ0011: "호출 결과처리(리소스 부족) 오류 입니다.",
OPSQ1002: "세션 연결 오류.",
OPSQ2000: "ERROR : INPUT INVALID_CHECK_ACNO",
OPSQ2001: "ERROR : INPUT INVALID_CHECK_MRKT_DIV_CODE",
OPSQ2002: "ERROR : INPUT INVALID_CHECK_FIELD_LENGTH",
OPSQ2003: "ERROR : SET_MCI_SEND_DATA",
OPSQ3001: "ERROR : RESPONSE_ADDITEMTOOBJECT",
OPSQ3002: "ERROR : GET_CALL_PARAM_MCI_SEND_DATA_LEN",
OPSQ3004: "ERROR : OUT_STRING_ARRAY ALLOC FAILED",
OPSQ9995: "JSON PARSING ERROR : body not found",
OPSQ9996: "JSON PARSING ERROR : header not found",
OPSQ9997: "JSON PARSING ERROR : invalid json format",
OPSQ9998: "JSON PARSING ERROR : seq_no not found",
OPSQ9999: "JSON PARSING ERROR : tr_id not found",
OPSP0000: "SUBSCRIBE SUCCESS",
OPSP0001: "UNSUBSCRIBE SUCCESS",
OPSP0002: "ALREADY IN SUBSCRIBE",
OPSP0003: "UNSUBSCRIBE ERROR(not found!)",
OPSP0007: "SUBSCRIBE INTERNAL ERROR",
OPSP0008: "MAX SUBSCRIBE OVER",
OPSP0009: "SUBSCRIBE ERROR : mci send failed",
OPSP0010: "SUBSCRIBE WARNNING : invalid appkey",
OPSP0011: "invalid approval(appkey) : NOT FOUND",
OPSP8991: "SUBSCRIBE ERROR : invalid tr_id",
OPSP8992: "SUBSCRIBE ERROR : invalid tr_key",
OPSP8993: "JSON PARSING ERROR : invalid tr_key",
OPSP8994: "JSON PARSING ERROR : personalseckey not found",
OPSP8995: "JSON PARSING ERROR : appsecret not found",
OPSP8996: "ALREADY IN USE appkey",
OPSP8997: "JSON PARSING ERROR : invalid tr_type",
OPSP8998: "JSON PARSING ERROR : invalid custtype",
OPSP8999: "resource not available (ALLOC_CALL_PARAM)",
OPSP9990: "JSON PARSING ERROR : tr_key not found",
OPSP9991: "JSON PARSING ERROR : input not found",
OPSP9992: "JSON PARSING ERROR : body not found",
OPSP9993: "JSON PARSING ERROR : internal error",
OPSP9994: "JSON PARSING ERROR : INVALID appkey",
OPSP9995: "JSON PARSING ERROR : resource not available",
OPSP9996: "JSON PARSING ERROR : appkey",
OPSP9997: "JSON PARSING ERROR : custtype not found",
OPSP9998: "JSON PARSING ERROR : header not found",
OPSP9999: "JSON PARSING ERROR : invalid json format",
} as const;
export interface KisErrorGuide {
code: string;
message: string;
referenceUrl: string;
}
interface BuildKisErrorDetailParams {
message?: string;
msgCode?: string;
extraMessages?: Array<string | undefined>;
}
function normalizeKisErrorCode(msgCode?: string) {
return msgCode?.trim().toUpperCase() ?? "";
}
/**
* @description KIS msg_cd를 공식 FAQ 문구와 매칭합니다.
* @param msgCode KIS 응답 msg_cd
* @returns 코드/문구/참고 URL 정보. 없으면 null
* @see lib/kis/client.ts kisGet/kisPost 비즈니스 오류 메시지 구성
* @see features/kis-realtime/stores/kisWebSocketStore.ts 실시간 제어 오류 안내문 구성
*/
export function getKisErrorGuide(msgCode?: string): KisErrorGuide | null {
const code = normalizeKisErrorCode(msgCode);
if (!code) return null;
const message =
KIS_ERROR_CODE_MESSAGE_MAP[
code as keyof typeof KIS_ERROR_CODE_MESSAGE_MAP
];
if (!message) return null;
return {
code,
message,
referenceUrl: KIS_ERROR_CODE_REFERENCE_URL,
};
}
/**
* @description KIS 오류 조각(msg1/msg_cd/부가메시지)을 사람이 읽기 쉬운 한 줄로 합칩니다.
* @param params 오류 문자열 조합 입력값
* @returns 중복 제거된 상세 메시지
* @see lib/kis/token.ts buildTokenIssueDetail 토큰 발급/폐기 오류 상세 구성
* @see lib/kis/approval.ts issueKisApprovalKey 승인키 발급 오류 상세 구성
*/
export function buildKisErrorDetail({
message,
msgCode,
extraMessages = [],
}: BuildKisErrorDetailParams) {
const tokens = new Set<string>();
for (const raw of [...extraMessages, message]) {
const normalized = raw?.trim();
if (normalized) tokens.add(normalized);
}
const guide = getKisErrorGuide(msgCode);
const normalizedCode = normalizeKisErrorCode(msgCode);
if (guide) {
tokens.add(`${guide.code} (${guide.message})`);
} else if (normalizedCode) {
tokens.add(normalizedCode);
}
return [...tokens].join(" / ");
}

View File

@@ -1,11 +1,12 @@
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
import type { NextRequest } from "next/server";
import { z } from "zod";
interface KisCredentialRequestBody {
appKey?: string;
appSecret?: string;
tradingEnv?: string;
}
const kisCredentialRequestBodySchema = z.object({
appKey: z.string().trim().optional(),
appSecret: z.string().trim().optional(),
tradingEnv: z.string().optional(),
});
/**
* @description 요청 본문에서 KIS 인증 정보를 파싱합니다.
@@ -14,14 +15,17 @@ interface KisCredentialRequestBody {
export async function parseKisCredentialRequest(
request: NextRequest,
): Promise<KisCredentialInput> {
let body: KisCredentialRequestBody = {};
let rawBody: unknown = {};
try {
body = (await request.json()) as KisCredentialRequestBody;
rawBody = (await request.json()) as unknown;
} catch {
// 빈 본문 또는 JSON 파싱 실패는 아래 필수값 검증에서 처리합니다.
}
const parsedBody = kisCredentialRequestBodySchema.safeParse(rawBody);
const body = parsedBody.success ? parsedBody.data : {};
return {
appKey: body.appKey?.trim(),
appSecret: body.appSecret?.trim(),

View File

@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
import type { KisConfig, KisCredentialInput } from "@/lib/kis/config";
import { getKisConfig } from "@/lib/kis/config";
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
/**
* @file lib/kis/token.ts
@@ -218,9 +219,11 @@ function buildTokenIssueBody(config: KisConfig) {
* @see issueKisToken 토큰 발급 실패 에러 메시지 구성
*/
function buildTokenIssueDetail(payload: KisTokenResponse) {
return [payload.msg1, payload.error_description, payload.error, payload.msg_cd]
.filter(Boolean)
.join(" / ");
return buildKisErrorDetail({
message: payload.msg1,
msgCode: payload.msg_cd,
extraMessages: [payload.error_description, payload.error],
});
}
/**
@@ -321,7 +324,10 @@ export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
const isSuccessCode = code === "" || code === "200";
if (!response.ok || !isSuccessCode) {
const detail = [payload.message, payload.msg1].filter(Boolean).join(" / ");
const detail = buildKisErrorDetail({
message: payload.message,
extraMessages: [payload.msg1],
});
throw new Error(
detail