차트 수정

This commit is contained in:
2026-02-11 11:18:15 +09:00
parent e5a518b211
commit 89bad1d141
13 changed files with 927 additions and 333 deletions

View File

@@ -5,6 +5,11 @@ import type {
} 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
@@ -129,6 +134,10 @@ interface DomesticOverviewResult {
marketPhase: DomesticMarketPhase;
}
interface DomesticSessionAwareOptions {
sessionOverride?: string | null;
}
/**
* 국내주식 현재가 조회
* @param symbol 6자리 종목코드
@@ -238,10 +247,18 @@ export async function getDomesticOvertimePrice(
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>(
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
"FHKST01010200",
apiPath,
trId,
{
FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(),
FID_INPUT_ISCD: symbol,
@@ -271,8 +288,12 @@ export async function getDomesticOverview(
symbol: string,
fallbackMeta?: DashboardStockFallbackMeta,
credentials?: KisCredentialInput,
options?: DomesticSessionAwareOptions,
): Promise<DomesticOverviewResult> {
const marketPhase = getDomesticMarketPhaseInKst();
const marketPhase = getDomesticMarketPhaseInKst(
new Date(),
options?.sessionOverride,
);
const emptyQuote: KisDomesticQuoteOutput = {};
const emptyDaily: KisDomesticDailyPriceOutput[] = [];
const emptyCcnl: KisDomesticCcnlOutput = {};
@@ -375,7 +396,7 @@ export async function getDomesticOverview(
function toNumber(value?: string) {
if (!value) return 0;
const normalized = value.replaceAll(",", "").trim();
const normalized = value.replace(/,/g, "").trim();
if (!normalized) return 0;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : 0;
@@ -383,7 +404,7 @@ function toNumber(value?: string) {
function toOptionalNumber(value?: string) {
if (!value) return undefined;
const normalized = value.replaceAll(",", "").trim();
const normalized = value.replace(/,/g, "").trim();
if (!normalized) return undefined;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : undefined;
@@ -454,24 +475,13 @@ 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 getDomesticMarketPhaseInKst(
now = new Date(),
sessionOverride?: string | null,
): DomesticMarketPhase {
return mapDomesticKisSessionToMarketPhase(
resolveDomesticKisSession(sessionOverride, now),
);
}
function firstDefinedNumber(...values: Array<number | undefined>) {
@@ -629,7 +639,7 @@ function mergeCandlesByTimestamp(rows: StockCandlePoint[]) {
volume: (prev.volume ?? 0) + (row.volume ?? 0),
});
}
return [...map.values()].sort(
return Array.from(map.values()).sort(
(a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
);
}
@@ -699,12 +709,43 @@ function minutesForTimeframe(tf: DashboardChartTimeframe) {
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-itemchartprice (FHKST03010200)
* - 분봉 (과거): inquire-time-dailychartprice (FHKST03010230)
*/
export async function getDomesticChart(
symbol: string,
@@ -743,7 +784,7 @@ export async function getDomesticChart(
new Date(oldest.timestamp * 1000)
.toISOString()
.slice(0, 10)
.replaceAll("-", ""),
.replace(/-/g, ""),
-1,
)
: null;
@@ -751,27 +792,100 @@ export async function getDomesticChart(
return { candles: parsed, hasMore: Boolean(nextCursor), nextCursor };
}
// ── 분봉 (1m / 30m / 1h) — 당일 데이터만 제공 ──
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,
);
// ── 분봉 (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(
parseOutput2Rows(response)
rawRows
.map((row) => parseMinuteCandleRow(row, minuteBucket))
.filter((c): c is StockCandlePoint => Boolean(c)),
);
// 당일 분봉만 제공되므로 과거 페이징 불필요
return { candles, hasMore: false, nextCursor: null };
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`;
}