865 lines
24 KiB
TypeScript
865 lines
24 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";
|
|
|
|
/**
|
|
* @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<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,
|
|
) {
|
|
const response = await kisGet<KisDomesticOrderBookOutput>(
|
|
"/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<DomesticOverviewResult> {
|
|
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<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()): 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<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;
|
|
}
|
|
|
|
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<number, StockCandlePoint>();
|
|
|
|
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<string, unknown>;
|
|
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<DomesticChartResult> {
|
|
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<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: 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<unknown>(
|
|
"/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}`;
|
|
}
|