스킬 정리 및 리팩토링
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 비즈니스 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
|
||||
269
lib/kis/dashboard-helpers.ts
Normal file
269
lib/kis/dashboard-helpers.ts
Normal 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);
|
||||
}
|
||||
@@ -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
349
lib/kis/domestic-helpers.ts
Normal 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")}`;
|
||||
}
|
||||
@@ -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
188
lib/kis/error-codes.ts
Normal 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(" / ");
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user