import type { DashboardChartTimeframe, DashboardStockItem, StockCandlePoint, } from "@/features/trade/types/trade.types"; import type { KisCredentialInput } from "@/lib/kis/config"; import { kisGet } from "@/lib/kis/client"; import { mapDomesticKisSessionToMarketPhase, 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 * @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; } 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; } interface DomesticSessionAwareOptions { sessionOverride?: string | null; } /** * 국내주식 현재가 조회 * @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, options?: DomesticSessionAwareOptions, ) { const session = resolveDomesticKisSession(options?.sessionOverride); const useOvertimeApi = shouldUseOvertimeOrderBookApi(session); const apiPath = useOvertimeApi ? "/uapi/domestic-stock/v1/quotations/inquire-overtime-asking-price" : "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn"; const trId = useOvertimeApi ? "FHPST02300400" : "FHKST01010200"; const response = await kisGet( apiPath, trId, { 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, options?: DomesticSessionAwareOptions, ): Promise { const marketPhase = getDomesticMarketPhaseInKst( new Date(), options?.sessionOverride, ); 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 getDomesticMarketPhaseInKst( now = new Date(), sessionOverride?: string | null, ): DomesticMarketPhase { return mapDomesticKisSessionToMarketPhase( resolveDomesticKisSession(sessionOverride, now), ); } function resolvePriceMarketDivCode() { return "J"; } export interface DomesticChartResult { candles: StockCandlePoint[]; nextCursor: string | null; hasMore: boolean; } /** * 국내주식 주식일별분봉조회 (과거 분봉) * @param symbol 종목코드 * @param date 조회할 날짜 (YYYYMMDD) * @param time 조회할 기준 시간 (HHMMSS) - 이 시간부터 과거로 조회 * @param credentials */ export async function getDomesticDailyTimeChart( symbol: string, date: string, time: string, credentials?: KisCredentialInput, ) { const response = await kisGet( "/uapi/domestic-stock/v1/quotations/inquire-time-dailychartprice", "FHKST03010230", { FID_COND_MRKT_DIV_CODE: "J", FID_INPUT_ISCD: symbol, FID_INPUT_DATE_1: date, FID_INPUT_HOUR_1: time, FID_PW_DATA_INCU_YN: "N", FID_FAKE_TICK_INCU_YN: "", }, credentials, ); return parseOutput2Rows(response); } // ─── 차트 데이터 조회 메인 ───────────────────────────────── /** * 종목 차트 데이터 조회 (일봉/주봉/분봉) * - 일봉/주봉: inquire-daily-itemchartprice (FHKST03010100), 과거 페이징 지원 * - 분봉 (오늘): inquire-time-itemchartprice (FHKST03010200) * - 분봉 (과거): inquire-time-dailychartprice (FHKST03010230) */ 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 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: timeframe === "1w" ? "W" : "D", FID_ORG_ADJ_PRC: "1", }, credentials, ); const parsed = parseOutput2Rows(response) .map(parseDayCandleRow) .filter((c): c is StockCandlePoint => Boolean(c)) .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) .replace(/-/g, ""), -1, ) : null; return { candles: parsed, hasMore: Boolean(nextCursor), nextCursor }; } // ── 분봉 (1m / 30m / 1h) ── const minuteBucket = minutesForTimeframe(timeframe); let rawRows: Array> = []; let nextCursor: string | null = null; // Case A: 과거 데이터 조회 (커서 존재) if (cursor && cursor.length >= 8) { const targetDate = cursor.slice(0, 8); const targetTime = cursor.slice(8) || "153000"; rawRows = await getDomesticDailyTimeChart( symbol, targetDate, targetTime, credentials, ); // 다음 커서 계산 // 데이터가 있으면 가장 오래된 시간 - 1분? 혹은 해당 날짜의 09:00 도달 시 전일로 이동 // API가 시간 역순으로 데이터를 준다고 가정 (output[0]이 가장 최신, output[last]가 가장 과거) // 실제 KIS API는 보통 최신순 정렬 if (rawRows.length > 0) { // 가장 과거 데이터의 시간 확인 const oldestRow = rawRows[rawRows.length - 1]; // 마지막이 가장 과거라 가정 const oldestTime = readRowString(oldestRow, "stck_cntg_hour"); // 09:00:00보다 크면 계속 같은 날짜 페이징 (단, KIS가 120건씩 주므로) // 만약 09시 근처라면 전일로 이동 // 간단히: 가져온 데이터 중 090000이 포함되어 있거나, 더 가져올 게 없어 보이면 전일로 // 여기서는 단순히 전일 153000으로 넘어가는 로직을 사용하거나, // 현재 날짜에서 시간을 줄여서 재요청해야 함. // KIS API가 '다음 커서'를 주지 않으므로, 마지막 데이터 시간을 기준으로 다음 요청 if (oldestTime && Number(oldestTime) > 90000) { // 같은 날짜, 시간만 조정 (1분 전) // HHMMSS -> number -> subtract -> string // 편의상 120개 꽉 찼으면 마지막 시간 사용, 아니면 전일로 if (rawRows.length >= 120) { nextCursor = targetDate + oldestTime; // 다음 요청 시 이 시간 '이전'을 달라고 해야 함 (Inclusive 여부 확인 필요) // 만약 Inclusive라면 -1분 해야 함. 안전하게 -1분 처리 // 시간 연산이 복잡하므로, 단순히 전일로 넘어가는 게 나을 수도 있으나, // 하루치 분봉이 380개라 120개로는 부족함. // 따라서 시간 연산 필요. nextCursor = targetDate + subOneMinute(oldestTime); } else { // 120개 미만이면 장 시작까지 다 가져왔다고 가정 -> 전일로 nextCursor = shiftYmd(targetDate, -1) + "153000"; } } else { // 09:00 도달 -> 전일로 nextCursor = shiftYmd(targetDate, -1) + "153000"; } } else { // 데이터 없음 (휴장일 등) -> 전일로 계속 시도 (최대 5일? 무한 루프 방지 필요하나 UI에서 제어) nextCursor = shiftYmd(targetDate, -1) + "153000"; // 너무 과거(1년)면 중단? 일단 생략 } } else { // Case B: 초기 진입 (오늘 실시간/장중 데이터) 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: nowHmsInKst(), FID_PW_DATA_INCU_YN: "Y", FID_ETC_CLS_CODE: "", }, credentials, ); rawRows = parseOutput2Rows(response); // 초기 조회는 "오늘 가장 오래된 분봉" 기준으로 같은 날 이전 분봉을 우선 이어 붙입니다. const oldestRow = rawRows[rawRows.length - 1]; const oldestTimeRaw = oldestRow ? readRowString(oldestRow, "stck_cntg_hour", "STCK_CNTG_HOUR") : ""; const oldestDateRaw = oldestRow ? readRowString(oldestRow, "stck_bsop_date", "STCK_BSOP_DATE") : ""; const oldestTime = /^\d{6}$/.test(oldestTimeRaw) ? oldestTimeRaw : /^\d{4}$/.test(oldestTimeRaw) ? `${oldestTimeRaw}00` : ""; const oldestDate = /^\d{8}$/.test(oldestDateRaw) ? oldestDateRaw : nowYmdInKst(); nextCursor = oldestTime && Number(oldestTime) > 90000 ? oldestDate + subOneMinute(oldestTime) : shiftYmd(oldestDate, -1) + "153000"; } const candles = mergeCandlesByTimestamp( rawRows .map((row) => parseMinuteCandleRow(row, minuteBucket)) .filter((c): c is StockCandlePoint => Boolean(c)), ); return { candles, hasMore: Boolean(nextCursor), nextCursor }; }