Files
auto-trade/lib/kis/domestic.ts
2026-02-11 11:18:15 +09:00

892 lines
26 KiB
TypeScript

import type {
DashboardChartTimeframe,
DashboardStockItem,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { kisGet } from "@/lib/kis/client";
import {
mapDomesticKisSessionToMarketPhase,
resolveDomesticKisSession,
shouldUseOvertimeOrderBookApi,
} from "@/lib/kis/domestic-market-session";
/**
* @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;
}
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<KisDomesticQuoteOutput>(
"/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<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(),
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 ?? {};
}
/**
* 국내주식 호가(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<KisDomesticOrderBookOutput>(
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<DomesticOverviewResult> {
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 toNumber(value?: string) {
if (!value) return 0;
const normalized = value.replace(/,/g, "").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.replace(/,/g, "").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 ?? "",
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(),
sessionOverride?: string | null,
): DomesticMarketPhase {
return mapDomesticKisSessionToMarketPhase(
resolveDomesticKisSession(sessionOverride, now),
);
}
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() {
return "J";
}
function firstPositive(...values: number[]) {
return values.find((value) => value > 0) ?? 0;
}
export interface DomesticChartResult {
candles: StockCandlePoint[];
nextCursor: string | null;
hasMore: boolean;
}
// ─── KIS output2 배열 추출 ─────────────────────────────────
function parseOutput2Rows(envelope: {
output2?: unknown;
output1?: unknown;
output?: unknown;
}) {
if (Array.isArray(envelope.output2))
return envelope.output2 as KisDomesticItemChartRow[];
if (Array.isArray(envelope.output))
return envelope.output as KisDomesticItemChartRow[];
for (const key of ["output2", "output", "output1"] as const) {
const v = envelope[key];
if (v && typeof v === "object" && !Array.isArray(v))
return [v as KisDomesticItemChartRow];
}
return [];
}
// ─── Row → StockCandlePoint 변환 ───────────────────────────
function readRowString(row: KisDomesticItemChartRow, ...keys: string[]) {
const record = row as Record<string, unknown>;
for (const key of keys) {
const v = record[key];
if (typeof v === "string" && v.trim()) return v.trim();
}
return "";
}
function readOhlcv(row: KisDomesticItemChartRow) {
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 { open, high, low, close, volume };
}
function parseDayCandleRow(
row: KisDomesticItemChartRow,
): StockCandlePoint | null {
const date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE");
if (!/^\d{8}$/.test(date)) return null;
const ohlcv = readOhlcv(row);
if (!ohlcv) return null;
return {
time: formatDate(date),
timestamp: toKstTimestamp(date, "090000"),
price: ohlcv.close,
...ohlcv,
};
}
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 ohlcv = readOhlcv(row);
if (!ohlcv) return null;
const bucketed = alignTimeToMinuteBucket(time, minuteBucket);
return {
time: `${bucketed.slice(0, 2)}:${bucketed.slice(2, 4)}`,
timestamp: toKstTimestamp(date, bucketed),
price: ohlcv.close,
...ohlcv,
};
}
// ─── 같은 타임스탬프 봉 병합 ───────────────────────────────
function mergeCandlesByTimestamp(rows: StockCandlePoint[]) {
const map = new Map<number, StockCandlePoint>();
for (const row of rows) {
if (!row.timestamp) continue;
const prev = map.get(row.timestamp);
if (!prev) {
map.set(row.timestamp, row);
continue;
}
map.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 Array.from(map.values()).sort(
(a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
);
}
// ─── 시간 유틸 ─────────────────────────────────────────────
function alignTimeToMinuteBucket(hhmmss: string, bucket: number) {
if (/^\d{4}$/.test(hhmmss)) hhmmss = `${hhmmss}00`;
if (bucket <= 1) return hhmmss;
const hh = Number(hhmmss.slice(0, 2));
const mm = Number(hhmmss.slice(2, 4));
const aligned = Math.floor(mm / bucket) * bucket;
return `${hh.toString().padStart(2, "0")}${aligned.toString().padStart(2, "0")}00`;
}
function toKstTimestamp(yyyymmdd: string, hhmmss: string) {
const y = Number(yyyymmdd.slice(0, 4));
const mo = Number(yyyymmdd.slice(4, 6));
const d = 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(y, mo - 1, d, hh - 9, mm, ss) / 1000);
}
function toYmd(date: Date) {
return `${date.getUTCFullYear()}${`${date.getUTCMonth() + 1}`.padStart(2, "0")}${`${date.getUTCDate()}`.padStart(2, "0")}`;
}
function shiftYmd(ymd: string, days: number) {
const utc = new Date(
Date.UTC(
Number(ymd.slice(0, 4)),
Number(ymd.slice(4, 6)) - 1,
Number(ymd.slice(6, 8)),
),
);
utc.setUTCDate(utc.getUTCDate() + days);
return toYmd(utc);
}
function nowYmdInKst() {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const m = new Map(parts.map((p) => [p.type, p.value]));
return `${m.get("year")}${m.get("month")}${m.get("day")}`;
}
function nowHmsInKst() {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Seoul",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(new Date());
const m = new Map(parts.map((p) => [p.type, p.value]));
return `${m.get("hour")}${m.get("minute")}${m.get("second")}`;
}
function minutesForTimeframe(tf: DashboardChartTimeframe) {
if (tf === "30m") return 30;
if (tf === "1h") return 60;
return 1;
}
/**
* 국내주식 주식일별분봉조회 (과거 분봉)
* @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<unknown>(
"/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<DomesticChartResult> {
// ── 일봉 / 주봉 ──
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<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: 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: KisDomesticItemChartRow[] = [];
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<unknown>(
"/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 todayYmd = nowYmdInKst();
nextCursor = shiftYmd(todayYmd, -1) + "153000";
}
const candles = mergeCandlesByTimestamp(
rawRows
.map((row) => parseMinuteCandleRow(row, minuteBucket))
.filter((c): c is StockCandlePoint => Boolean(c)),
);
return { candles, hasMore: Boolean(nextCursor), nextCursor };
}
function subOneMinute(hhmmss: string) {
const hh = Number(hhmmss.slice(0, 2));
const mm = Number(hhmmss.slice(2, 4));
let totalMin = hh * 60 + mm - 1;
if (totalMin < 0) totalMin = 0;
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return `${String(h).padStart(2, '0')}${String(m).padStart(2, '0')}00`;
}