대시보드 중간 커밋

This commit is contained in:
2026-02-10 11:16:39 +09:00
parent 851a2acd69
commit 89b13ac308
52 changed files with 6955 additions and 1287 deletions

View File

@@ -58,7 +58,66 @@ export async function kisGet<TOutput>(
if (!response.ok) {
const detail = payload.msg1 || rawText.slice(0, 200);
throw new Error(detail ? `KIS API 요청 실패 (${response.status}): ${detail}` : `KIS API 요청 실패 (${response.status})`);
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 POST 호출 (주문 등)
* @param apiPath REST 경로
* @param trId KIS TR ID
* @param body 요청 본문
* @param credentials 사용자 입력 키(선택)
* @returns KIS 원본 응답
*/
export async function kisPost<TOutput>(
apiPath: string,
trId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: Record<string, any>,
credentials?: KisCredentialInput,
): Promise<KisApiEnvelope<TOutput>> {
const config = getKisConfig(credentials);
const token = await getKisAccessToken(credentials);
const url = new URL(apiPath, config.baseUrl);
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"content-type": "application/json; charset=utf-8",
authorization: `Bearer ${token}`,
appkey: config.appKey,
appsecret: config.appSecret,
tr_id: trId,
tr_cont: "",
custtype: "P",
},
body: JSON.stringify(body),
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") {
@@ -75,7 +134,9 @@ export async function kisGet<TOutput>(
* @returns KisApiEnvelope
* @see lib/kis/token.ts tryParseTokenResponse - 토큰 발급 응답 파싱 방식과 동일 패턴
*/
function tryParseKisEnvelope<TOutput>(rawText: string): KisApiEnvelope<TOutput> {
function tryParseKisEnvelope<TOutput>(
rawText: string,
): KisApiEnvelope<TOutput> {
try {
return JSON.parse(rawText) as KisApiEnvelope<TOutput>;
} catch {
@@ -85,5 +146,7 @@ function tryParseKisEnvelope<TOutput>(rawText: string): KisApiEnvelope<TOutput>
}
}
// 하위 호환(alias)
// 하위 호환(alias)
export const kisMockGet = kisGet;
export const kisMockPost = kisPost;

View File

@@ -1,4 +1,8 @@
import type { DashboardStockItem, StockCandlePoint } from "@/features/dashboard/types/dashboard.types";
import type {
DashboardChartTimeframe,
DashboardStockItem,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { kisGet } from "@/lib/kis/client";
@@ -44,7 +48,68 @@ interface KisDomesticOvertimePriceOutput {
interface KisDomesticDailyPriceOutput {
stck_bsop_date?: string;
stck_oprc?: string;
stck_hgpr?: string;
stck_lwpr?: string;
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;
total_bidp_rsqn?: string;
askp1?: string;
askp2?: string;
askp3?: string;
askp4?: string;
askp5?: string;
askp6?: string;
askp7?: string;
askp8?: string;
askp9?: string;
askp10?: string;
bidp1?: string;
bidp2?: string;
bidp3?: string;
bidp4?: string;
bidp5?: string;
bidp6?: string;
bidp7?: string;
bidp8?: string;
bidp9?: string;
bidp10?: string;
askp_rsqn1?: string;
askp_rsqn2?: string;
askp_rsqn3?: string;
askp_rsqn4?: string;
askp_rsqn5?: string;
askp_rsqn6?: string;
askp_rsqn7?: string;
askp_rsqn8?: string;
askp_rsqn9?: string;
askp_rsqn10?: string;
bidp_rsqn1?: string;
bidp_rsqn2?: string;
bidp_rsqn3?: string;
bidp_rsqn4?: string;
bidp_rsqn5?: string;
bidp_rsqn6?: string;
bidp_rsqn7?: string;
bidp_rsqn8?: string;
bidp_rsqn9?: string;
bidp_rsqn10?: string;
}
interface DashboardStockFallbackMeta {
@@ -53,7 +118,10 @@ interface DashboardStockFallbackMeta {
}
export type DomesticMarketPhase = "regular" | "afterHours";
export type DomesticPriceSource = "inquire-price" | "inquire-ccnl" | "inquire-overtime-price";
export type DomesticPriceSource =
| "inquire-price"
| "inquire-ccnl"
| "inquire-overtime-price";
interface DomesticOverviewResult {
stock: DashboardStockItem;
@@ -67,12 +135,15 @@ interface DomesticOverviewResult {
* @param credentials 사용자 입력 키
* @returns KIS 현재가 output
*/
export async function getDomesticQuote(symbol: string, credentials?: KisCredentialInput) {
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_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(),
FID_INPUT_ISCD: symbol,
},
credentials,
@@ -87,7 +158,10 @@ export async function getDomesticQuote(symbol: string, credentials?: KisCredenti
* @param credentials 사용자 입력 키
* @returns KIS 일봉 output 배열
*/
export async function getDomesticDailyPrice(symbol: string, credentials?: KisCredentialInput) {
export async function getDomesticDailyPrice(
symbol: string,
credentials?: KisCredentialInput,
) {
const response = await kisGet<KisDomesticDailyPriceOutput[]>(
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
"FHKST01010400",
@@ -110,12 +184,17 @@ export async function getDomesticDailyPrice(symbol: string, credentials?: KisCre
* @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[]>(
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_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(),
FID_INPUT_ISCD: symbol,
},
credentials,
@@ -133,7 +212,10 @@ export async function getDomesticConclusion(symbol: string, credentials?: KisCre
* @returns KIS 시간외 현재가 output
* @see app/api/kis/domestic/overview/route.ts GET - 대시보드 상세 응답에서 장외 가격 우선 소스
*/
export async function getDomesticOvertimePrice(symbol: string, credentials?: KisCredentialInput) {
export async function getDomesticOvertimePrice(
symbol: string,
credentials?: KisCredentialInput,
) {
const response = await kisGet<KisDomesticOvertimePriceOutput>(
"/uapi/domestic-stock/v1/quotations/inquire-overtime-price",
"FHPST02300000",
@@ -147,6 +229,37 @@ export async function getDomesticOvertimePrice(symbol: string, credentials?: Kis
return response.output ?? {};
}
/**
* 국내주식 호가(10단계) 조회
* @param symbol 6자리 종목코드
* @param credentials 사용자 입력 키
* @returns KIS 호가 output
*/
export async function getDomesticOrderBook(
symbol: string,
credentials?: KisCredentialInput,
) {
const response = await kisGet<KisDomesticOrderBookOutput>(
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
"FHKST01010200",
{
FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(),
FID_INPUT_ISCD: symbol,
},
credentials,
);
if (response.output && typeof response.output === "object") {
return response.output;
}
if (response.output1 && typeof response.output1 === "object") {
return response.output1 as KisDomesticOrderBookOutput;
}
return {};
}
/**
* 현재가 + 일봉을 대시보드 모델로 변환
* @param symbol 6자리 종목코드
@@ -160,12 +273,14 @@ export async function getDomesticOverview(
credentials?: KisCredentialInput,
): Promise<DomesticOverviewResult> {
const marketPhase = getDomesticMarketPhaseInKst();
const emptyQuote: KisDomesticQuoteOutput = {};
const emptyDaily: KisDomesticDailyPriceOutput[] = [];
const emptyCcnl: KisDomesticCcnlOutput = {};
const emptyOvertime: KisDomesticOvertimePriceOutput = {};
const [quote, daily, ccnl, overtime] = await Promise.all([
getDomesticQuote(symbol, credentials),
getDomesticDailyPrice(symbol, credentials),
getDomesticQuote(symbol, credentials).catch(() => emptyQuote),
getDomesticDailyPrice(symbol, credentials).catch(() => emptyDaily),
getDomesticConclusion(symbol, credentials).catch(() => emptyCcnl),
marketPhase === "afterHours"
? getDomesticOvertimePrice(symbol, credentials).catch(() => emptyOvertime)
@@ -179,7 +294,12 @@ export async function getDomesticOverview(
toOptionalNumber(quote.stck_prpr),
) ?? 0;
const currentPriceSource = resolveCurrentPriceSource(marketPhase, overtime, ccnl, quote);
const currentPriceSource = resolveCurrentPriceSource(
marketPhase,
overtime,
ccnl,
quote,
);
const rawChange =
firstDefinedNumber(
@@ -188,8 +308,11 @@ export async function getDomesticOverview(
toOptionalNumber(quote.prdy_vrss),
) ?? 0;
const signCode =
firstDefinedString(ccnl.prdy_vrss_sign, overtime.ovtm_untp_prdy_vrss_sign, quote.prdy_vrss_sign);
const signCode = firstDefinedString(
ccnl.prdy_vrss_sign,
overtime.ovtm_untp_prdy_vrss_sign,
quote.prdy_vrss_sign,
);
const change = normalizeSignedValue(rawChange, signCode);
@@ -214,7 +337,11 @@ export async function getDomesticOverview(
stock: {
symbol,
name: quote.hts_kor_isnm?.trim() || fallbackMeta?.name || symbol,
market: resolveMarket(quote.rprs_mrkt_kor_name, quote.bstp_kor_isnm, fallbackMeta?.market),
market: resolveMarket(
quote.rprs_mrkt_kor_name,
quote.bstp_kor_isnm,
fallbackMeta?.market,
),
currentPrice,
change,
changeRate,
@@ -272,27 +399,55 @@ function normalizeSignedValue(value: number, signCode?: string) {
function resolveMarket(...values: Array<string | undefined>) {
const merged = values.filter(Boolean).join(" ");
if (merged.includes("코스닥") || merged.toUpperCase().includes("KOSDAQ")) return "KOSDAQ" as const;
if (merged.includes("코스닥") || merged.toUpperCase().includes("KOSDAQ"))
return "KOSDAQ" as const;
return "KOSPI" as const;
}
function toCandles(rows: KisDomesticDailyPriceOutput[], currentPrice: number): StockCandlePoint[] {
function toCandles(
rows: KisDomesticDailyPriceOutput[],
currentPrice: number,
): StockCandlePoint[] {
const parsed = rows
.map((row) => ({
date: row.stck_bsop_date ?? "",
price: toNumber(row.stck_clpr),
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.price > 0)
.filter((item) => item.date.length === 8 && item.close > 0)
.sort((a, b) => a.date.localeCompare(b.date))
.slice(-20)
.slice(-80)
.map((item) => ({
time: formatDate(item.date),
price: item.price,
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;
return [{ time: "오늘", price: Math.max(currentPrice, 0) }];
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) {
@@ -333,7 +488,8 @@ function resolveCurrentPriceSource(
ccnl: KisDomesticCcnlOutput,
quote: KisDomesticQuoteOutput,
): DomesticPriceSource {
const hasOvertimePrice = toOptionalNumber(overtime.ovtm_untp_prpr) !== undefined;
const hasOvertimePrice =
toOptionalNumber(overtime.ovtm_untp_prpr) !== undefined;
const hasCcnlPrice = toOptionalNumber(ccnl.stck_prpr) !== undefined;
const hasQuotePrice = toOptionalNumber(quote.stck_prpr) !== undefined;
@@ -348,10 +504,361 @@ function resolveCurrentPriceSource(
return "inquire-price";
}
function resolvePriceMarketDivCode(credentials?: KisCredentialInput) {
return credentials?.tradingEnv === "mock" ? "J" : "UN";
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;
}
interface MinuteCursor {
date: string;
hour: string;
}
function parseOutput2Rows(envelope: {
output2?: unknown;
output1?: unknown;
output?: unknown;
}) {
if (Array.isArray(envelope.output2)) {
return envelope.output2 as KisDomesticItemChartRow[];
}
if (envelope.output2 && typeof envelope.output2 === "object") {
return [envelope.output2 as KisDomesticItemChartRow];
}
if (Array.isArray(envelope.output)) {
return envelope.output as KisDomesticItemChartRow[];
}
if (envelope.output && typeof envelope.output === "object") {
return [envelope.output as KisDomesticItemChartRow];
}
if (envelope.output1 && typeof envelope.output1 === "object") {
return [envelope.output1 as KisDomesticItemChartRow];
}
return [];
}
function parseDayCandleRow(
row: KisDomesticItemChartRow,
): StockCandlePoint | null {
const date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE");
if (!/^\d{8}$/.test(date)) return 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 {
time: formatDate(date),
timestamp: toKstTimestamp(date, "090000"),
price: close,
open,
high,
low,
close,
volume,
};
}
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 close = toNumber(
readRowString(row, "stck_prpr", "STCK_PRPR") ||
readRowString(row, "stck_clpr", "STCK_CLPR"),
);
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, "cntg_vol", "CNTG_VOL") ||
readRowString(row, "acml_vol", "ACML_VOL"),
);
const bucketed = alignTimeToMinuteBucket(time, minuteBucket);
return {
time: `${bucketed.slice(0, 2)}:${bucketed.slice(2, 4)}`,
timestamp: toKstTimestamp(date, bucketed),
price: close,
open,
high,
low,
close,
volume,
};
}
function mergeCandlesByTimestamp(rows: StockCandlePoint[]) {
const byTs = new Map<number, StockCandlePoint>();
for (const row of rows) {
if (!row.timestamp) continue;
const prev = byTs.get(row.timestamp);
if (!prev) {
byTs.set(row.timestamp, row);
continue;
}
byTs.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 [...byTs.values()].sort(
(a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
);
}
function alignTimeToMinuteBucket(hhmmss: string, minuteBucket: number) {
if (/^\d{4}$/.test(hhmmss)) {
hhmmss = `${hhmmss}00`;
}
if (minuteBucket <= 1) return hhmmss;
const hh = Number(hhmmss.slice(0, 2));
const mm = Number(hhmmss.slice(2, 4));
const alignedMinute = Math.floor(mm / minuteBucket) * minuteBucket;
return `${hh.toString().padStart(2, "0")}${alignedMinute.toString().padStart(2, "0")}00`;
}
function readRowString(row: KisDomesticItemChartRow, ...keys: string[]) {
const record = row as Record<string, unknown>;
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return "";
}
function toKstTimestamp(yyyymmdd: string, hhmmss: string) {
const year = Number(yyyymmdd.slice(0, 4));
const month = Number(yyyymmdd.slice(4, 6));
const day = 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(year, month - 1, day, hh - 9, mm, ss) / 1000);
}
function toYmd(date: Date) {
const year = date.getUTCFullYear();
const month = `${date.getUTCMonth() + 1}`.padStart(2, "0");
const day = `${date.getUTCDate()}`.padStart(2, "0");
return `${year}${month}${day}`;
}
function shiftYmd(ymd: string, days: number) {
const year = Number(ymd.slice(0, 4));
const month = Number(ymd.slice(4, 6));
const day = Number(ymd.slice(6, 8));
const utc = new Date(Date.UTC(year, month - 1, day));
utc.setUTCDate(utc.getUTCDate() + days);
return toYmd(utc);
}
function nowYmdInKst() {
const now = new Date();
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const parts = formatter.formatToParts(now);
const map = new Map(parts.map((p) => [p.type, p.value]));
return `${map.get("year")}${map.get("month")}${map.get("day")}`;
}
function nowHmsInKst() {
const now = new Date();
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Seoul",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const parts = formatter.formatToParts(now);
const map = new Map(parts.map((p) => [p.type, p.value]));
return `${map.get("hour")}${map.get("minute")}${map.get("second")}`;
}
function minutesForTimeframe(timeframe: DashboardChartTimeframe) {
if (timeframe === "30m") return 30;
if (timeframe === "1h") return 60;
return 1;
}
export async function getDomesticChart(
symbol: string,
timeframe: DashboardChartTimeframe,
credentials?: KisCredentialInput,
cursor?: string,
): Promise<DomesticChartResult> {
if (timeframe === "1d" || timeframe === "1w") {
const endDate = cursor && /^\d{8}$/.test(cursor) ? cursor : nowYmdInKst();
const startDate = shiftYmd(endDate, timeframe === "1w" ? -700 : -365);
const period = timeframe === "1w" ? "W" : "D";
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice",
"FHKST03010100",
{
FID_COND_MRKT_DIV_CODE: "J",
FID_INPUT_ISCD: symbol,
FID_INPUT_DATE_1: startDate,
FID_INPUT_DATE_2: endDate,
FID_PERIOD_DIV_CODE: period,
FID_ORG_ADJ_PRC: "1",
},
credentials,
);
const parsed = parseOutput2Rows(response)
.map(parseDayCandleRow)
.filter((item): item is StockCandlePoint => Boolean(item))
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
const oldest = parsed[0];
const nextCursor =
parsed.length >= 95 && oldest?.timestamp
? shiftYmd(
new Date(oldest.timestamp * 1000)
.toISOString()
.slice(0, 10)
.replaceAll("-", ""),
-1,
)
: null;
return {
candles: parsed,
hasMore: Boolean(nextCursor),
nextCursor,
};
}
// 분봉 조회 (1m, 30m, 1h)
// inquire-time-itemchartprice (FHKST03010200) 사용
// FID_PW_DATA_INCU_YN="Y" 설정 시 과거 데이터 포함하여 조회 가능
const minuteCursor = resolveMinuteCursor(cursor);
const minuteBucket = minutesForTimeframe(timeframe);
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice",
"FHKST03010200",
{
FID_COND_MRKT_DIV_CODE: "J",
FID_INPUT_ISCD: symbol,
FID_INPUT_HOUR_1: minuteCursor.hour, // 조회 시작 시간 (HHMMSS)
// FID_INPUT_DATE_1: minuteCursor.date, // 일자별 조회시에만 필요할 수 있으나, 당일분봉조회에는 보통 시간만으로 동작하거나 오늘 기준임. 문서상 필수 아닐 수 있음 확인 필요.
// 하지만 inquire-time-itemchartprice에는 FID_INPUT_DATE_1 파라미터가 명시되지 않은 경우가 많음.
// API 문서를 확인해보면 inquire-time-itemchartprice는 입력 시간이 종료 시간 기준임.
FID_ETC_CLS_CODE: "",
FID_PW_DATA_INCU_YN: "Y", // 과거 데이터 포함
},
credentials,
);
const rows = parseOutput2Rows(response);
const parsed = rows
.map((row) => parseMinuteCandleRow(row, minuteBucket))
.filter((item): item is StockCandlePoint => Boolean(item));
const merged = mergeCandlesByTimestamp(parsed);
// 다음 커서 계산
const oldest = parsed
.filter((item) => typeof item.timestamp === "number")
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))[0];
// 120건(통상 최대치) 미만이면 더 이상 데이터가 없다고 판단할 수도 있으나,
// 안전하게 oldest timestamp 기준 1분 전을 다음 커서로 설정
const nextCursor =
rows.length >= 1 && oldest?.timestamp
? toMinuteCursorFromTimestamp(oldest.timestamp - 60)
: null;
return {
candles: merged,
hasMore: rows.length > 0 && Boolean(nextCursor), // 데이터가 있으면 더 있을 가능성 열어둠
nextCursor,
};
}
function resolveMinuteCursor(cursor?: string): MinuteCursor {
if (cursor && /^\d{14}$/.test(cursor)) {
return {
date: cursor.slice(0, 8),
hour: cursor.slice(8, 14),
};
}
return {
date: nowYmdInKst(),
hour: nowHmsInKst(),
};
}
function toMinuteCursorFromTimestamp(timestamp: number) {
const kstDate = new Date((timestamp + 9 * 3600) * 1000);
const year = `${kstDate.getUTCFullYear()}`;
const month = `${kstDate.getUTCMonth() + 1}`.padStart(2, "0");
const day = `${kstDate.getUTCDate()}`.padStart(2, "0");
const hour = `${kstDate.getUTCHours()}`.padStart(2, "0");
const minute = `${kstDate.getUTCMinutes()}`.padStart(2, "0");
const second = `${kstDate.getUTCSeconds()}`.padStart(2, "0");
return `${year}${month}${day}${hour}${minute}${second}`;
}

View File

@@ -1,4 +1,6 @@
import { createHash } from "node:crypto";
import { createHash } from "node:crypto";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { KisCredentialInput } from "@/lib/kis/config";
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
import { getKisConfig } from "@/lib/kis/config";
@@ -32,6 +34,7 @@ interface KisRevokeResponse {
const tokenCacheMap = new Map<string, KisTokenCache>();
const tokenIssueInFlightMap = new Map<string, Promise<KisTokenCache>>();
const TOKEN_REFRESH_BUFFER_MS = 60 * 1000;
const TOKEN_CACHE_FILE_PATH = join(process.cwd(), ".tmp", "kis-token-cache.json");
function hashKey(value: string) {
return createHash("sha256").update(value).digest("hex");
@@ -42,6 +45,62 @@ function getTokenCacheKey(credentials?: KisCredentialInput) {
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
}
interface PersistedTokenCache {
[cacheKey: string]: KisTokenCache;
}
async function readPersistedTokenCache() {
try {
const raw = await readFile(TOKEN_CACHE_FILE_PATH, "utf8");
return JSON.parse(raw) as PersistedTokenCache;
} catch {
return {};
}
}
async function writePersistedTokenCache(next: PersistedTokenCache) {
await mkdir(join(process.cwd(), ".tmp"), { recursive: true });
await writeFile(TOKEN_CACHE_FILE_PATH, JSON.stringify(next), "utf8");
}
async function getPersistedToken(cacheKey: string) {
const cache = await readPersistedTokenCache();
const token = cache[cacheKey];
if (!token) return null;
if (token.expiresAt - TOKEN_REFRESH_BUFFER_MS <= Date.now()) {
delete cache[cacheKey];
await writePersistedTokenCache(cache);
return null;
}
return token;
}
async function setPersistedToken(cacheKey: string, token: KisTokenCache) {
const cache = await readPersistedTokenCache();
cache[cacheKey] = token;
await writePersistedTokenCache(cache);
}
async function clearPersistedToken(cacheKey: string) {
const cache = await readPersistedTokenCache();
if (!(cacheKey in cache)) return;
delete cache[cacheKey];
if (Object.keys(cache).length === 0) {
try {
await unlink(TOKEN_CACHE_FILE_PATH);
} catch {
// ignore
}
return;
}
await writePersistedTokenCache(cache);
}
/**
* KIS access token 발급
* @param credentials 사용자 입력 키(선택)
@@ -159,6 +218,12 @@ export async function getKisAccessToken(credentials?: KisCredentialInput) {
return cached.token;
}
const persisted = await getPersistedToken(cacheKey);
if (persisted) {
tokenCacheMap.set(cacheKey, persisted);
return persisted.token;
}
// 같은 키로 동시에 요청이 들어오면 토큰 발급을 1회로 합칩니다.
const inFlight = tokenIssueInFlightMap.get(cacheKey);
if (inFlight) {
@@ -173,6 +238,7 @@ export async function getKisAccessToken(credentials?: KisCredentialInput) {
});
tokenCacheMap.set(cacheKey, next);
await setPersistedToken(cacheKey, next);
return next.token;
}
@@ -216,6 +282,7 @@ export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
tokenCacheMap.delete(cacheKey);
tokenIssueInFlightMap.delete(cacheKey);
await clearPersistedToken(cacheKey);
clearKisApprovalKeyCache(credentials);
return payload.message ?? "접근토큰 폐기에 성공하였습니다.";

80
lib/kis/trade.ts Normal file
View File

@@ -0,0 +1,80 @@
import { kisPost } from "@/lib/kis/client";
import { KisCredentialInput } from "@/lib/kis/config";
import {
DashboardOrderSide,
DashboardOrderType,
} from "@/features/dashboard/types/dashboard.types";
/**
* @file lib/kis/trade.ts
* @description KIS 주식 주문/잔고 관련 API
*/
export interface KisOrderCashOutput {
KRX_FWDG_ORD_ORGNO?: string; // 한국거래소전송주문조직번호
ODNO?: string; // 주문번호
ORD_TMD?: string; // 주문시각
}
interface KisOrderCashBody {
CANO: string; // 종합계좌번호(8자리)
ACNT_PRDT_CD: string; // 계좌상품코드(2자리)
PDNO: string; // 종목코드
ORD_DVSN: string; // 주문구분(00:지정가, 01:시장가...)
ORD_QTY: string; // 주문수량
ORD_UNPR: string; // 주문단가
}
/**
* 현금 주문(매수/매도) 실행
*/
export async function executeOrderCash(
params: {
symbol: string;
side: DashboardOrderSide;
orderType: DashboardOrderType;
quantity: number;
price: number;
accountNo: string;
accountProductCode: string;
},
credentials?: KisCredentialInput,
) {
const trId = resolveOrderTrId(params.side, credentials?.tradingEnv);
const ordDvsn = resolveOrderDivision(params.orderType);
const body: KisOrderCashBody = {
CANO: params.accountNo,
ACNT_PRDT_CD: params.accountProductCode,
PDNO: params.symbol,
ORD_DVSN: ordDvsn,
ORD_QTY: String(params.quantity),
ORD_UNPR: String(params.price),
};
const response = await kisPost<KisOrderCashOutput>(
"/uapi/domestic-stock/v1/trading/order-cash",
trId,
body,
credentials,
);
return response.output ?? {};
}
function resolveOrderTrId(side: DashboardOrderSide, env?: "real" | "mock") {
const isMock = env === "mock";
if (side === "buy") {
// 매수
return isMock ? "VTTC0802U" : "TTTC0802U";
} else {
// 매도
return isMock ? "VTTC0801U" : "TTTC0801U";
}
}
function resolveOrderDivision(type: DashboardOrderType) {
// 00: 지정가, 01: 시장가
if (type === "market") return "01";
return "00";
}