대시보드
This commit is contained in:
142
lib/kis/approval.ts
Normal file
142
lib/kis/approval.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config";
|
||||
|
||||
/**
|
||||
* @file lib/kis/approval.ts
|
||||
* @description KIS 웹소켓 approval key 발급/캐시 관리
|
||||
*/
|
||||
|
||||
interface KisApprovalResponse {
|
||||
approval_key?: string;
|
||||
msg1?: string;
|
||||
msg_cd?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
interface KisApprovalCache {
|
||||
approvalKey: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const approvalCacheMap = new Map<string, KisApprovalCache>();
|
||||
const approvalIssueInFlightMap = new Map<string, Promise<KisApprovalCache>>();
|
||||
const APPROVAL_REFRESH_BUFFER_MS = 60 * 1000;
|
||||
|
||||
function hashKey(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function getApprovalCacheKey(credentials?: KisCredentialInput) {
|
||||
const config = getKisConfig(credentials);
|
||||
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 웹소켓 approval key 발급
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns approval key + expiresAt
|
||||
* @see app/api/kis/ws/approval/route.ts POST - 실시간 차트 연결 시 호출
|
||||
*/
|
||||
async function issueKisApprovalKey(credentials?: KisCredentialInput): Promise<KisApprovalCache> {
|
||||
const config = getKisConfig(credentials);
|
||||
|
||||
const response = await fetch(`${config.baseUrl}/oauth2/Approval`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "client_credentials",
|
||||
appkey: config.appKey,
|
||||
secretkey: config.appSecret,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
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(" / ");
|
||||
|
||||
throw new Error(
|
||||
detail
|
||||
? `KIS 웹소켓 승인키 발급 실패 (${config.tradingEnv}, ${response.status}): ${detail}`
|
||||
: `KIS 웹소켓 승인키 발급 실패 (${config.tradingEnv}, ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 공식 샘플은 1일 단위 재발급을 권장하므로 토큰과 동일하게 보수적으로 23시간 캐시합니다.
|
||||
return {
|
||||
approvalKey: payload.approval_key,
|
||||
expiresAt: Date.now() + 23 * 60 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* approval 응답을 안전하게 JSON으로 파싱합니다.
|
||||
* @param rawText fetch 응답 원문
|
||||
* @returns KisApprovalResponse
|
||||
*/
|
||||
function tryParseApprovalResponse(rawText: string): KisApprovalResponse {
|
||||
try {
|
||||
return JSON.parse(rawText) as KisApprovalResponse;
|
||||
} catch {
|
||||
return {
|
||||
msg1: rawText.slice(0, 200),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹소켓 승인키를 반환합니다.
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns approval key
|
||||
*/
|
||||
export async function getKisApprovalKey(credentials?: KisCredentialInput) {
|
||||
const cacheKey = getApprovalCacheKey(credentials);
|
||||
const cached = approvalCacheMap.get(cacheKey);
|
||||
|
||||
if (cached && cached.expiresAt - APPROVAL_REFRESH_BUFFER_MS > Date.now()) {
|
||||
return cached.approvalKey;
|
||||
}
|
||||
|
||||
const inFlight = approvalIssueInFlightMap.get(cacheKey);
|
||||
if (inFlight) {
|
||||
const shared = await inFlight;
|
||||
return shared.approvalKey;
|
||||
}
|
||||
|
||||
const nextPromise = issueKisApprovalKey(credentials);
|
||||
approvalIssueInFlightMap.set(cacheKey, nextPromise);
|
||||
const next = await nextPromise.finally(() => {
|
||||
approvalIssueInFlightMap.delete(cacheKey);
|
||||
});
|
||||
|
||||
approvalCacheMap.set(cacheKey, next);
|
||||
return next.approvalKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 모드에 맞는 KIS 웹소켓 URL을 반환합니다.
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns websocket url
|
||||
*/
|
||||
export function resolveKisWebSocketUrl(credentials?: KisCredentialInput) {
|
||||
const config = getKisConfig(credentials);
|
||||
return getKisWebSocketUrl(config.tradingEnv);
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인키 캐시를 제거합니다.
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
*/
|
||||
export function clearKisApprovalKeyCache(credentials?: KisCredentialInput) {
|
||||
const cacheKey = getApprovalCacheKey(credentials);
|
||||
approvalCacheMap.delete(cacheKey);
|
||||
approvalIssueInFlightMap.delete(cacheKey);
|
||||
}
|
||||
89
lib/kis/client.ts
Normal file
89
lib/kis/client.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { getKisConfig } from "@/lib/kis/config";
|
||||
import { getKisAccessToken } from "@/lib/kis/token";
|
||||
|
||||
/**
|
||||
* @file lib/kis/client.ts
|
||||
* @description KIS REST 공통 클라이언트(실전/모의 공통)
|
||||
*/
|
||||
|
||||
export interface KisApiEnvelope<TOutput> {
|
||||
rt_cd?: string;
|
||||
msg_cd?: string;
|
||||
msg1?: string;
|
||||
output?: TOutput;
|
||||
output1?: unknown;
|
||||
output2?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS GET 호출
|
||||
* @param apiPath REST 경로
|
||||
* @param trId KIS TR ID
|
||||
* @param params 쿼리 파라미터
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns KIS 원본 응답
|
||||
* @see lib/kis/domestic.ts getDomesticQuote/getDomesticDailyPrice - 대시보드 시세 데이터 소스
|
||||
*/
|
||||
export async function kisGet<TOutput>(
|
||||
apiPath: string,
|
||||
trId: string,
|
||||
params: Record<string, string>,
|
||||
credentials?: KisCredentialInput,
|
||||
): Promise<KisApiEnvelope<TOutput>> {
|
||||
const config = getKisConfig(credentials);
|
||||
const token = await getKisAccessToken(credentials);
|
||||
|
||||
const url = new URL(apiPath, config.baseUrl);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== "") url.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
authorization: `Bearer ${token}`,
|
||||
appkey: config.appKey,
|
||||
appsecret: config.appSecret,
|
||||
tr_id: trId,
|
||||
tr_cont: "",
|
||||
custtype: "P",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
const payload = tryParseKisEnvelope<TOutput>(rawText);
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = payload.msg1 || rawText.slice(0, 200);
|
||||
throw new Error(detail ? `KIS API 요청 실패 (${response.status}): ${detail}` : `KIS API 요청 실패 (${response.status})`);
|
||||
}
|
||||
|
||||
if (payload.rt_cd && payload.rt_cd !== "0") {
|
||||
const detail = [payload.msg1, payload.msg_cd].filter(Boolean).join(" / ");
|
||||
throw new Error(detail || "KIS API 비즈니스 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 응답을 안전하게 JSON으로 파싱합니다.
|
||||
* @param rawText fetch 응답 원문
|
||||
* @returns KisApiEnvelope
|
||||
* @see lib/kis/token.ts tryParseTokenResponse - 토큰 발급 응답 파싱 방식과 동일 패턴
|
||||
*/
|
||||
function tryParseKisEnvelope<TOutput>(rawText: string): KisApiEnvelope<TOutput> {
|
||||
try {
|
||||
return JSON.parse(rawText) as KisApiEnvelope<TOutput>;
|
||||
} catch {
|
||||
return {
|
||||
msg1: rawText.slice(0, 200),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 하위 호환(alias)
|
||||
export const kisMockGet = kisGet;
|
||||
122
lib/kis/config.ts
Normal file
122
lib/kis/config.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @file lib/kis/config.ts
|
||||
* @description KIS 거래 환경(real/mock) 설정과 키/도메인 로딩
|
||||
*/
|
||||
|
||||
export type KisTradingEnv = "real" | "mock";
|
||||
|
||||
export interface KisCredentialInput {
|
||||
tradingEnv?: KisTradingEnv;
|
||||
appKey?: string;
|
||||
appSecret?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export interface KisConfig {
|
||||
tradingEnv: KisTradingEnv;
|
||||
appKey: string;
|
||||
appSecret: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
const DEFAULT_KIS_REAL_BASE_URL = "https://openapi.koreainvestment.com:9443";
|
||||
const DEFAULT_KIS_MOCK_BASE_URL = "https://openapivts.koreainvestment.com:29443";
|
||||
const DEFAULT_KIS_REAL_WS_URL = "ws://ops.koreainvestment.com:21000";
|
||||
const DEFAULT_KIS_MOCK_WS_URL = "ws://ops.koreainvestment.com:31000";
|
||||
|
||||
/**
|
||||
* 거래 환경 문자열을 정규화합니다.
|
||||
* @param value 환경값
|
||||
* @returns real | mock
|
||||
*/
|
||||
export function normalizeTradingEnv(value?: string): KisTradingEnv {
|
||||
return (value ?? "mock").toLowerCase() === "real" ? "real" : "mock";
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 거래 환경을 반환합니다.
|
||||
* @returns real | mock
|
||||
*/
|
||||
export function getKisTradingEnv() {
|
||||
return normalizeTradingEnv(process.env.KIS_TRADING_ENV);
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 웹소켓 URL을 반환합니다.
|
||||
* @param tradingEnvInput 거래 모드(real/mock)
|
||||
* @returns websocket base url
|
||||
*/
|
||||
export function getKisWebSocketUrl(tradingEnvInput?: KisTradingEnv) {
|
||||
const tradingEnv = normalizeTradingEnv(tradingEnvInput);
|
||||
|
||||
if (tradingEnv === "real") {
|
||||
return process.env.KIS_WS_URL_REAL ?? DEFAULT_KIS_REAL_WS_URL;
|
||||
}
|
||||
|
||||
return process.env.KIS_WS_URL_MOCK ?? DEFAULT_KIS_MOCK_WS_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 준비 여부를 확인합니다.
|
||||
* @param input 외부(사용자 입력) 키가 있으면 우선 사용
|
||||
* @returns 사용 가능 여부
|
||||
*/
|
||||
export function hasKisConfig(input?: KisCredentialInput) {
|
||||
if (input?.appKey && input?.appSecret) return true;
|
||||
|
||||
const env = getKisTradingEnv();
|
||||
if (env === "real") {
|
||||
return Boolean(process.env.KIS_APP_KEY_REAL && process.env.KIS_APP_SECRET_REAL);
|
||||
}
|
||||
|
||||
return Boolean(process.env.KIS_APP_KEY_MOCK && process.env.KIS_APP_SECRET_MOCK);
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 호출에 필요한 설정을 반환합니다.
|
||||
* @param input 사용자 입력 키(선택)
|
||||
* @returns tradingEnv/appKey/appSecret/baseUrl
|
||||
*/
|
||||
export function getKisConfig(input?: KisCredentialInput): KisConfig {
|
||||
if (input?.appKey && input?.appSecret) {
|
||||
const tradingEnv = normalizeTradingEnv(input.tradingEnv);
|
||||
const baseUrl =
|
||||
input.baseUrl ??
|
||||
(tradingEnv === "real" ? DEFAULT_KIS_REAL_BASE_URL : DEFAULT_KIS_MOCK_BASE_URL);
|
||||
|
||||
return {
|
||||
tradingEnv,
|
||||
appKey: input.appKey,
|
||||
appSecret: input.appSecret,
|
||||
baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const tradingEnv = getKisTradingEnv();
|
||||
|
||||
if (tradingEnv === "real") {
|
||||
const appKey = process.env.KIS_APP_KEY_REAL;
|
||||
const appSecret = process.env.KIS_APP_SECRET_REAL;
|
||||
const baseUrl = process.env.KIS_BASE_URL_REAL ?? DEFAULT_KIS_REAL_BASE_URL;
|
||||
|
||||
if (!appKey || !appSecret) {
|
||||
throw new Error(
|
||||
"KIS 실전투자 키가 없습니다. KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL 환경변수를 설정하세요.",
|
||||
);
|
||||
}
|
||||
|
||||
return { tradingEnv, appKey, appSecret, baseUrl };
|
||||
}
|
||||
|
||||
const appKey = process.env.KIS_APP_KEY_MOCK;
|
||||
const appSecret = process.env.KIS_APP_SECRET_MOCK;
|
||||
const baseUrl = process.env.KIS_BASE_URL_MOCK ?? DEFAULT_KIS_MOCK_BASE_URL;
|
||||
|
||||
if (!appKey || !appSecret) {
|
||||
throw new Error(
|
||||
"KIS 모의투자 키가 없습니다. KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK 환경변수를 설정하세요.",
|
||||
);
|
||||
}
|
||||
|
||||
return { tradingEnv, appKey, appSecret, baseUrl };
|
||||
}
|
||||
357
lib/kis/domestic.ts
Normal file
357
lib/kis/domestic.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import type { DashboardStockItem, StockCandlePoint } from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { kisGet } from "@/lib/kis/client";
|
||||
|
||||
/**
|
||||
* @file lib/kis/domestic.ts
|
||||
* @description KIS 국내주식(현재가/일봉) 조회와 대시보드 모델 변환
|
||||
*/
|
||||
|
||||
interface KisDomesticQuoteOutput {
|
||||
hts_kor_isnm?: string;
|
||||
rprs_mrkt_kor_name?: string;
|
||||
bstp_kor_isnm?: string;
|
||||
stck_prpr?: string;
|
||||
prdy_vrss?: string;
|
||||
prdy_vrss_sign?: string;
|
||||
prdy_ctrt?: string;
|
||||
stck_oprc?: string;
|
||||
stck_hgpr?: string;
|
||||
stck_lwpr?: string;
|
||||
stck_sdpr?: string;
|
||||
stck_prdy_clpr?: string;
|
||||
acml_vol?: string;
|
||||
}
|
||||
|
||||
interface KisDomesticCcnlOutput {
|
||||
stck_prpr?: string;
|
||||
prdy_vrss?: string;
|
||||
prdy_vrss_sign?: string;
|
||||
prdy_ctrt?: string;
|
||||
cntg_vol?: string;
|
||||
}
|
||||
|
||||
interface KisDomesticOvertimePriceOutput {
|
||||
ovtm_untp_prpr?: string;
|
||||
ovtm_untp_prdy_vrss?: string;
|
||||
ovtm_untp_prdy_vrss_sign?: string;
|
||||
ovtm_untp_prdy_ctrt?: string;
|
||||
ovtm_untp_vol?: string;
|
||||
ovtm_untp_oprc?: string;
|
||||
ovtm_untp_hgpr?: string;
|
||||
ovtm_untp_lwpr?: string;
|
||||
}
|
||||
|
||||
interface KisDomesticDailyPriceOutput {
|
||||
stck_bsop_date?: string;
|
||||
stck_clpr?: string;
|
||||
}
|
||||
|
||||
interface DashboardStockFallbackMeta {
|
||||
name?: string;
|
||||
market?: "KOSPI" | "KOSDAQ";
|
||||
}
|
||||
|
||||
export type DomesticMarketPhase = "regular" | "afterHours";
|
||||
export type DomesticPriceSource = "inquire-price" | "inquire-ccnl" | "inquire-overtime-price";
|
||||
|
||||
interface DomesticOverviewResult {
|
||||
stock: DashboardStockItem;
|
||||
priceSource: DomesticPriceSource;
|
||||
marketPhase: DomesticMarketPhase;
|
||||
}
|
||||
|
||||
/**
|
||||
* 국내주식 현재가 조회
|
||||
* @param symbol 6자리 종목코드
|
||||
* @param credentials 사용자 입력 키
|
||||
* @returns KIS 현재가 output
|
||||
*/
|
||||
export async function getDomesticQuote(symbol: string, credentials?: KisCredentialInput) {
|
||||
const response = await kisGet<KisDomesticQuoteOutput>(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||
"FHKST01010100",
|
||||
{
|
||||
FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(credentials),
|
||||
FID_INPUT_ISCD: symbol,
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
return response.output ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 국내주식 일자별 시세 조회
|
||||
* @param symbol 6자리 종목코드
|
||||
* @param credentials 사용자 입력 키
|
||||
* @returns KIS 일봉 output 배열
|
||||
*/
|
||||
export async function getDomesticDailyPrice(symbol: string, credentials?: KisCredentialInput) {
|
||||
const response = await kisGet<KisDomesticDailyPriceOutput[]>(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
|
||||
"FHKST01010400",
|
||||
{
|
||||
FID_COND_MRKT_DIV_CODE: "J",
|
||||
FID_INPUT_ISCD: symbol,
|
||||
FID_PERIOD_DIV_CODE: "D",
|
||||
FID_ORG_ADJ_PRC: "1",
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
return Array.isArray(response.output) ? response.output : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 국내주식 현재가 체결 조회
|
||||
* @param symbol 6자리 종목코드
|
||||
* @param credentials 사용자 입력 키
|
||||
* @returns KIS 체결 output
|
||||
* @see app/api/kis/domestic/overview/route.ts GET - 대시보드 상세 응답에서 장중 가격 우선 소스
|
||||
*/
|
||||
export async function getDomesticConclusion(symbol: string, credentials?: KisCredentialInput) {
|
||||
const response = await kisGet<KisDomesticCcnlOutput | KisDomesticCcnlOutput[]>(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-ccnl",
|
||||
"FHKST01010300",
|
||||
{
|
||||
FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(credentials),
|
||||
FID_INPUT_ISCD: symbol,
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
const output = response.output;
|
||||
if (Array.isArray(output)) return output[0] ?? {};
|
||||
return output ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 국내주식 시간외 현재가 조회
|
||||
* @param symbol 6자리 종목코드
|
||||
* @param credentials 사용자 입력 키
|
||||
* @returns KIS 시간외 현재가 output
|
||||
* @see app/api/kis/domestic/overview/route.ts GET - 대시보드 상세 응답에서 장외 가격 우선 소스
|
||||
*/
|
||||
export async function getDomesticOvertimePrice(symbol: string, credentials?: KisCredentialInput) {
|
||||
const response = await kisGet<KisDomesticOvertimePriceOutput>(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-overtime-price",
|
||||
"FHPST02300000",
|
||||
{
|
||||
FID_COND_MRKT_DIV_CODE: "J",
|
||||
FID_INPUT_ISCD: symbol,
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
return response.output ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재가 + 일봉을 대시보드 모델로 변환
|
||||
* @param symbol 6자리 종목코드
|
||||
* @param fallbackMeta 보정 메타(종목명/시장)
|
||||
* @param credentials 사용자 입력 키
|
||||
* @returns DashboardStockItem
|
||||
*/
|
||||
export async function getDomesticOverview(
|
||||
symbol: string,
|
||||
fallbackMeta?: DashboardStockFallbackMeta,
|
||||
credentials?: KisCredentialInput,
|
||||
): Promise<DomesticOverviewResult> {
|
||||
const marketPhase = getDomesticMarketPhaseInKst();
|
||||
const emptyCcnl: KisDomesticCcnlOutput = {};
|
||||
const emptyOvertime: KisDomesticOvertimePriceOutput = {};
|
||||
|
||||
const [quote, daily, ccnl, overtime] = await Promise.all([
|
||||
getDomesticQuote(symbol, credentials),
|
||||
getDomesticDailyPrice(symbol, credentials),
|
||||
getDomesticConclusion(symbol, credentials).catch(() => emptyCcnl),
|
||||
marketPhase === "afterHours"
|
||||
? getDomesticOvertimePrice(symbol, credentials).catch(() => emptyOvertime)
|
||||
: Promise.resolve(emptyOvertime),
|
||||
]);
|
||||
|
||||
const currentPrice =
|
||||
firstDefinedNumber(
|
||||
toOptionalNumber(ccnl.stck_prpr),
|
||||
toOptionalNumber(overtime.ovtm_untp_prpr),
|
||||
toOptionalNumber(quote.stck_prpr),
|
||||
) ?? 0;
|
||||
|
||||
const currentPriceSource = resolveCurrentPriceSource(marketPhase, overtime, ccnl, quote);
|
||||
|
||||
const rawChange =
|
||||
firstDefinedNumber(
|
||||
toOptionalNumber(ccnl.prdy_vrss),
|
||||
toOptionalNumber(overtime.ovtm_untp_prdy_vrss),
|
||||
toOptionalNumber(quote.prdy_vrss),
|
||||
) ?? 0;
|
||||
|
||||
const signCode =
|
||||
firstDefinedString(ccnl.prdy_vrss_sign, overtime.ovtm_untp_prdy_vrss_sign, quote.prdy_vrss_sign);
|
||||
|
||||
const change = normalizeSignedValue(rawChange, signCode);
|
||||
|
||||
const rawChangeRate =
|
||||
firstDefinedNumber(
|
||||
toOptionalNumber(ccnl.prdy_ctrt),
|
||||
toOptionalNumber(overtime.ovtm_untp_prdy_ctrt),
|
||||
toOptionalNumber(quote.prdy_ctrt),
|
||||
) ?? 0;
|
||||
|
||||
const changeRate = normalizeSignedValue(rawChangeRate, signCode);
|
||||
|
||||
const prevClose = firstPositive(
|
||||
toNumber(quote.stck_sdpr),
|
||||
toNumber(quote.stck_prdy_clpr),
|
||||
Math.max(currentPrice - change, 0),
|
||||
);
|
||||
|
||||
const candles = toCandles(daily, currentPrice);
|
||||
|
||||
return {
|
||||
stock: {
|
||||
symbol,
|
||||
name: quote.hts_kor_isnm?.trim() || fallbackMeta?.name || symbol,
|
||||
market: resolveMarket(quote.rprs_mrkt_kor_name, quote.bstp_kor_isnm, fallbackMeta?.market),
|
||||
currentPrice,
|
||||
change,
|
||||
changeRate,
|
||||
open: firstPositive(
|
||||
toNumber(overtime.ovtm_untp_oprc),
|
||||
toNumber(quote.stck_oprc),
|
||||
currentPrice,
|
||||
),
|
||||
high: firstPositive(
|
||||
toNumber(overtime.ovtm_untp_hgpr),
|
||||
toNumber(quote.stck_hgpr),
|
||||
currentPrice,
|
||||
),
|
||||
low: firstPositive(
|
||||
toNumber(overtime.ovtm_untp_lwpr),
|
||||
toNumber(quote.stck_lwpr),
|
||||
currentPrice,
|
||||
),
|
||||
prevClose,
|
||||
volume: firstPositive(
|
||||
toNumber(overtime.ovtm_untp_vol),
|
||||
toNumber(quote.acml_vol),
|
||||
toNumber(ccnl.cntg_vol),
|
||||
),
|
||||
candles,
|
||||
},
|
||||
priceSource: currentPriceSource,
|
||||
marketPhase,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 ?? "",
|
||||
price: toNumber(row.stck_clpr),
|
||||
}))
|
||||
.filter((item) => item.date.length === 8 && item.price > 0)
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.slice(-20)
|
||||
.map((item) => ({
|
||||
time: formatDate(item.date),
|
||||
price: item.price,
|
||||
}));
|
||||
|
||||
if (parsed.length > 0) return parsed;
|
||||
|
||||
return [{ time: "오늘", price: Math.max(currentPrice, 0) }];
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
return `${date.slice(4, 6)}/${date.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
function getDomesticMarketPhaseInKst(now = new Date()): DomesticMarketPhase {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Asia/Seoul",
|
||||
weekday: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(now);
|
||||
|
||||
const partMap = new Map(parts.map((part) => [part.type, part.value]));
|
||||
const weekday = partMap.get("weekday");
|
||||
const hour = Number(partMap.get("hour") ?? "0");
|
||||
const minute = Number(partMap.get("minute") ?? "0");
|
||||
const totalMinutes = hour * 60 + minute;
|
||||
|
||||
if (weekday === "Sat" || weekday === "Sun") return "afterHours";
|
||||
if (totalMinutes >= 9 * 60 && totalMinutes < 15 * 60 + 30) return "regular";
|
||||
return "afterHours";
|
||||
}
|
||||
|
||||
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(credentials?: KisCredentialInput) {
|
||||
return credentials?.tradingEnv === "mock" ? "J" : "UN";
|
||||
}
|
||||
|
||||
function firstPositive(...values: number[]) {
|
||||
return values.find((value) => value > 0) ?? 0;
|
||||
}
|
||||
238
lib/kis/token.ts
Normal file
238
lib/kis/token.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
|
||||
import { getKisConfig } from "@/lib/kis/config";
|
||||
|
||||
/**
|
||||
* @file lib/kis/token.ts
|
||||
* @description KIS access token 발급/캐시 관리(실전/모의 공통)
|
||||
*/
|
||||
|
||||
interface KisTokenResponse {
|
||||
access_token?: string;
|
||||
access_token_token_expired?: string;
|
||||
expires_in?: number;
|
||||
msg1?: string;
|
||||
msg_cd?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
interface KisTokenCache {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface KisRevokeResponse {
|
||||
code?: number | string;
|
||||
message?: string;
|
||||
msg1?: string;
|
||||
}
|
||||
|
||||
const tokenCacheMap = new Map<string, KisTokenCache>();
|
||||
const tokenIssueInFlightMap = new Map<string, Promise<KisTokenCache>>();
|
||||
const TOKEN_REFRESH_BUFFER_MS = 60 * 1000;
|
||||
|
||||
function hashKey(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function getTokenCacheKey(credentials?: KisCredentialInput) {
|
||||
const config = getKisConfig(credentials);
|
||||
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS access token 발급
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns token + expiresAt
|
||||
* @see app/api/kis/validate/route.ts POST - 사용자 키 검증 시 토큰 발급 경로
|
||||
*/
|
||||
async function issueKisToken(credentials?: KisCredentialInput): Promise<KisTokenCache> {
|
||||
const config = getKisConfig(credentials);
|
||||
const tokenUrl = `${config.baseUrl}/oauth2/tokenP`;
|
||||
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "client_credentials",
|
||||
appkey: config.appKey,
|
||||
appsecret: config.appSecret,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
const payload = tryParseTokenResponse(rawText);
|
||||
|
||||
if (!response.ok || !payload.access_token) {
|
||||
const detail = [payload.msg1, payload.error_description, payload.error, payload.msg_cd]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
const hint = buildTokenIssueHint(detail, config.tradingEnv);
|
||||
|
||||
throw new Error(
|
||||
detail
|
||||
? `KIS 토큰 발급 실패 (${config.tradingEnv}, ${response.status}): ${detail}${hint}`
|
||||
: `KIS 토큰 발급 실패 (${config.tradingEnv}, ${response.status})${hint}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
token: payload.access_token,
|
||||
expiresAt: resolveTokenExpiry(payload),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 발급 실패 시 점검 안내를 생성합니다.
|
||||
* @param detail KIS 응답 메시지
|
||||
* @param tradingEnv 거래 모드(real/mock)
|
||||
* @returns 점검 안내 문자열
|
||||
* @see https://apiportal.koreainvestment.com/apiservice-apiservice
|
||||
*/
|
||||
function buildTokenIssueHint(detail: string, tradingEnv: "real" | "mock") {
|
||||
const lower = detail.toLowerCase();
|
||||
|
||||
const keyError =
|
||||
lower.includes("appkey") ||
|
||||
lower.includes("appsecret") ||
|
||||
lower.includes("secret") ||
|
||||
lower.includes("invalid") ||
|
||||
lower.includes("인증");
|
||||
|
||||
if (keyError) {
|
||||
return ` | 점검: ${tradingEnv === "real" ? "실전" : "모의"} 앱키/시크릿 쌍이 맞는지 확인하세요.`;
|
||||
}
|
||||
|
||||
return " | 점검: KIS API 포털에서 앱 상태(사용 가능/차단)와 실전·모의 구분을 다시 확인하세요.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 응답 문자열을 안전하게 JSON으로 변환합니다.
|
||||
* @param rawText fetch 응답 원문
|
||||
* @returns KisTokenResponse
|
||||
*/
|
||||
function tryParseTokenResponse(rawText: string): KisTokenResponse {
|
||||
try {
|
||||
return JSON.parse(rawText) as KisTokenResponse;
|
||||
} catch {
|
||||
// JSON 파싱 실패 시에도 호출부에서 상태코드 기반 에러를 만들 수 있게 기본 객체를 반환합니다.
|
||||
return {
|
||||
msg1: rawText.slice(0, 200),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 만료시각 계산
|
||||
* @param payload 토큰 응답
|
||||
* @returns epoch ms
|
||||
*/
|
||||
function resolveTokenExpiry(payload: KisTokenResponse) {
|
||||
if (payload.access_token_token_expired) {
|
||||
const parsed = Date.parse(payload.access_token_token_expired.replace(" ", "T"));
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
|
||||
if (typeof payload.expires_in === "number" && payload.expires_in > 0) {
|
||||
return Date.now() + payload.expires_in * 1000;
|
||||
}
|
||||
|
||||
return Date.now() + 23 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* access token 반환(환경/키 단위 메모리 캐시)
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns access token
|
||||
* @see lib/kis/domestic.ts getDomesticOverview - 현재가/일봉 병렬 조회 시 공용 토큰 사용
|
||||
*/
|
||||
export async function getKisAccessToken(credentials?: KisCredentialInput) {
|
||||
const cacheKey = getTokenCacheKey(credentials);
|
||||
const cached = tokenCacheMap.get(cacheKey);
|
||||
|
||||
if (cached && cached.expiresAt - TOKEN_REFRESH_BUFFER_MS > Date.now()) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
// 같은 키로 동시에 요청이 들어오면 토큰 발급을 1회로 합칩니다.
|
||||
const inFlight = tokenIssueInFlightMap.get(cacheKey);
|
||||
if (inFlight) {
|
||||
const shared = await inFlight;
|
||||
return shared.token;
|
||||
}
|
||||
|
||||
const nextPromise = issueKisToken(credentials);
|
||||
tokenIssueInFlightMap.set(cacheKey, nextPromise);
|
||||
const next = await nextPromise.finally(() => {
|
||||
tokenIssueInFlightMap.delete(cacheKey);
|
||||
});
|
||||
|
||||
tokenCacheMap.set(cacheKey, next);
|
||||
return next.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS access token 폐기 요청
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns 폐기 응답 메시지
|
||||
* @see app/api/kis/revoke/route.ts POST - 대시보드 접근 폐기 버튼 처리
|
||||
*/
|
||||
export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
|
||||
const config = getKisConfig(credentials);
|
||||
const cacheKey = getTokenCacheKey(credentials);
|
||||
const token = await getKisAccessToken(credentials);
|
||||
|
||||
const response = await fetch(`${config.baseUrl}/oauth2/revokeP`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
appkey: config.appKey,
|
||||
appsecret: config.appSecret,
|
||||
token,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
const payload = tryParseRevokeResponse(rawText);
|
||||
const code = payload.code != null ? String(payload.code) : "";
|
||||
const isSuccessCode = code === "" || code === "200";
|
||||
|
||||
if (!response.ok || !isSuccessCode) {
|
||||
const detail = [payload.message, payload.msg1].filter(Boolean).join(" / ");
|
||||
throw new Error(
|
||||
detail
|
||||
? `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status}): ${detail}`
|
||||
: `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
tokenCacheMap.delete(cacheKey);
|
||||
tokenIssueInFlightMap.delete(cacheKey);
|
||||
clearKisApprovalKeyCache(credentials);
|
||||
|
||||
return payload.message ?? "접근토큰 폐기에 성공하였습니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 폐기 응답 문자열을 안전하게 JSON으로 변환합니다.
|
||||
* @param rawText fetch 응답 원문
|
||||
* @returns KisRevokeResponse
|
||||
* @see https://apiportal.koreainvestment.com/apiservice-apiservice?/oauth2/revokeP
|
||||
*/
|
||||
function tryParseRevokeResponse(rawText: string): KisRevokeResponse {
|
||||
try {
|
||||
return JSON.parse(rawText) as KisRevokeResponse;
|
||||
} catch {
|
||||
return {
|
||||
message: rawText.slice(0, 200),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user