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( "/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( "/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( "/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( "/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 { 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) { 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) { 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(credentials?: KisCredentialInput) { return credentials?.tradingEnv === "mock" ? "J" : "UN"; } function firstPositive(...values: number[]) { return values.find((value) => value > 0) ?? 0; }