import type { DashboardChartTimeframe, 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_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 { 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( "/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100", { FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(), 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( "/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(), 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( "/uapi/domestic-stock/v1/quotations/inquire-overtime-price", "FHPST02300000", { FID_COND_MRKT_DIV_CODE: "J", FID_INPUT_ISCD: symbol, }, credentials, ); return response.output ?? {}; } /** * 국내주식 호가(10단계) 조회 * @param symbol 6자리 종목코드 * @param credentials 사용자 입력 키 * @returns KIS 호가 output */ export async function getDomesticOrderBook( symbol: string, credentials?: KisCredentialInput, ) { const response = await kisGet( "/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자리 종목코드 * @param fallbackMeta 보정 메타(종목명/시장) * @param credentials 사용자 입력 키 * @returns DashboardStockItem */ export async function getDomesticOverview( symbol: string, fallbackMeta?: DashboardStockFallbackMeta, credentials?: KisCredentialInput, ): Promise { 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).catch(() => emptyQuote), getDomesticDailyPrice(symbol, credentials).catch(() => emptyDaily), 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) { 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()): 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) { return values.find((value) => value !== undefined); } function firstDefinedString(...values: Array) { 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; } 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(); 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; 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 { 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( "/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( "/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}`; }