대시보드 중간 커밋
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import type { DashboardStockItem, StockCandlePoint } from "@/features/dashboard/types/dashboard.types";
|
||||
import type {
|
||||
DashboardChartTimeframe,
|
||||
DashboardStockItem,
|
||||
StockCandlePoint,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { kisGet } from "@/lib/kis/client";
|
||||
|
||||
@@ -44,7 +48,68 @@ interface KisDomesticOvertimePriceOutput {
|
||||
|
||||
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 {
|
||||
@@ -53,7 +118,10 @@ interface DashboardStockFallbackMeta {
|
||||
}
|
||||
|
||||
export type DomesticMarketPhase = "regular" | "afterHours";
|
||||
export type DomesticPriceSource = "inquire-price" | "inquire-ccnl" | "inquire-overtime-price";
|
||||
export type DomesticPriceSource =
|
||||
| "inquire-price"
|
||||
| "inquire-ccnl"
|
||||
| "inquire-overtime-price";
|
||||
|
||||
interface DomesticOverviewResult {
|
||||
stock: DashboardStockItem;
|
||||
@@ -67,12 +135,15 @@ interface DomesticOverviewResult {
|
||||
* @param credentials 사용자 입력 키
|
||||
* @returns KIS 현재가 output
|
||||
*/
|
||||
export async function getDomesticQuote(symbol: string, credentials?: KisCredentialInput) {
|
||||
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(credentials),
|
||||
FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(),
|
||||
FID_INPUT_ISCD: symbol,
|
||||
},
|
||||
credentials,
|
||||
@@ -87,7 +158,10 @@ export async function getDomesticQuote(symbol: string, credentials?: KisCredenti
|
||||
* @param credentials 사용자 입력 키
|
||||
* @returns KIS 일봉 output 배열
|
||||
*/
|
||||
export async function getDomesticDailyPrice(symbol: string, credentials?: KisCredentialInput) {
|
||||
export async function getDomesticDailyPrice(
|
||||
symbol: string,
|
||||
credentials?: KisCredentialInput,
|
||||
) {
|
||||
const response = await kisGet<KisDomesticDailyPriceOutput[]>(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
|
||||
"FHKST01010400",
|
||||
@@ -110,12 +184,17 @@ export async function getDomesticDailyPrice(symbol: string, credentials?: KisCre
|
||||
* @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[]>(
|
||||
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(credentials),
|
||||
FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(),
|
||||
FID_INPUT_ISCD: symbol,
|
||||
},
|
||||
credentials,
|
||||
@@ -133,7 +212,10 @@ export async function getDomesticConclusion(symbol: string, credentials?: KisCre
|
||||
* @returns KIS 시간외 현재가 output
|
||||
* @see app/api/kis/domestic/overview/route.ts GET - 대시보드 상세 응답에서 장외 가격 우선 소스
|
||||
*/
|
||||
export async function getDomesticOvertimePrice(symbol: string, credentials?: KisCredentialInput) {
|
||||
export async function getDomesticOvertimePrice(
|
||||
symbol: string,
|
||||
credentials?: KisCredentialInput,
|
||||
) {
|
||||
const response = await kisGet<KisDomesticOvertimePriceOutput>(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-overtime-price",
|
||||
"FHPST02300000",
|
||||
@@ -147,6 +229,37 @@ export async function getDomesticOvertimePrice(symbol: string, credentials?: Kis
|
||||
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자리 종목코드
|
||||
@@ -160,12 +273,14 @@ export async function getDomesticOverview(
|
||||
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),
|
||||
getDomesticDailyPrice(symbol, credentials),
|
||||
getDomesticQuote(symbol, credentials).catch(() => emptyQuote),
|
||||
getDomesticDailyPrice(symbol, credentials).catch(() => emptyDaily),
|
||||
getDomesticConclusion(symbol, credentials).catch(() => emptyCcnl),
|
||||
marketPhase === "afterHours"
|
||||
? getDomesticOvertimePrice(symbol, credentials).catch(() => emptyOvertime)
|
||||
@@ -179,7 +294,12 @@ export async function getDomesticOverview(
|
||||
toOptionalNumber(quote.stck_prpr),
|
||||
) ?? 0;
|
||||
|
||||
const currentPriceSource = resolveCurrentPriceSource(marketPhase, overtime, ccnl, quote);
|
||||
const currentPriceSource = resolveCurrentPriceSource(
|
||||
marketPhase,
|
||||
overtime,
|
||||
ccnl,
|
||||
quote,
|
||||
);
|
||||
|
||||
const rawChange =
|
||||
firstDefinedNumber(
|
||||
@@ -188,8 +308,11 @@ export async function getDomesticOverview(
|
||||
toOptionalNumber(quote.prdy_vrss),
|
||||
) ?? 0;
|
||||
|
||||
const signCode =
|
||||
firstDefinedString(ccnl.prdy_vrss_sign, overtime.ovtm_untp_prdy_vrss_sign, quote.prdy_vrss_sign);
|
||||
const signCode = firstDefinedString(
|
||||
ccnl.prdy_vrss_sign,
|
||||
overtime.ovtm_untp_prdy_vrss_sign,
|
||||
quote.prdy_vrss_sign,
|
||||
);
|
||||
|
||||
const change = normalizeSignedValue(rawChange, signCode);
|
||||
|
||||
@@ -214,7 +337,11 @@ export async function getDomesticOverview(
|
||||
stock: {
|
||||
symbol,
|
||||
name: quote.hts_kor_isnm?.trim() || fallbackMeta?.name || symbol,
|
||||
market: resolveMarket(quote.rprs_mrkt_kor_name, quote.bstp_kor_isnm, fallbackMeta?.market),
|
||||
market: resolveMarket(
|
||||
quote.rprs_mrkt_kor_name,
|
||||
quote.bstp_kor_isnm,
|
||||
fallbackMeta?.market,
|
||||
),
|
||||
currentPrice,
|
||||
change,
|
||||
changeRate,
|
||||
@@ -272,27 +399,55 @@ function normalizeSignedValue(value: number, signCode?: string) {
|
||||
|
||||
function resolveMarket(...values: Array<string | undefined>) {
|
||||
const merged = values.filter(Boolean).join(" ");
|
||||
if (merged.includes("코스닥") || merged.toUpperCase().includes("KOSDAQ")) return "KOSDAQ" as const;
|
||||
if (merged.includes("코스닥") || merged.toUpperCase().includes("KOSDAQ"))
|
||||
return "KOSDAQ" as const;
|
||||
return "KOSPI" as const;
|
||||
}
|
||||
|
||||
function toCandles(rows: KisDomesticDailyPriceOutput[], currentPrice: number): StockCandlePoint[] {
|
||||
function toCandles(
|
||||
rows: KisDomesticDailyPriceOutput[],
|
||||
currentPrice: number,
|
||||
): StockCandlePoint[] {
|
||||
const parsed = rows
|
||||
.map((row) => ({
|
||||
date: row.stck_bsop_date ?? "",
|
||||
price: toNumber(row.stck_clpr),
|
||||
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.price > 0)
|
||||
.filter((item) => item.date.length === 8 && item.close > 0)
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.slice(-20)
|
||||
.slice(-80)
|
||||
.map((item) => ({
|
||||
time: formatDate(item.date),
|
||||
price: item.price,
|
||||
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;
|
||||
|
||||
return [{ time: "오늘", price: Math.max(currentPrice, 0) }];
|
||||
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) {
|
||||
@@ -333,7 +488,8 @@ function resolveCurrentPriceSource(
|
||||
ccnl: KisDomesticCcnlOutput,
|
||||
quote: KisDomesticQuoteOutput,
|
||||
): DomesticPriceSource {
|
||||
const hasOvertimePrice = toOptionalNumber(overtime.ovtm_untp_prpr) !== undefined;
|
||||
const hasOvertimePrice =
|
||||
toOptionalNumber(overtime.ovtm_untp_prpr) !== undefined;
|
||||
const hasCcnlPrice = toOptionalNumber(ccnl.stck_prpr) !== undefined;
|
||||
const hasQuotePrice = toOptionalNumber(quote.stck_prpr) !== undefined;
|
||||
|
||||
@@ -348,10 +504,274 @@ function resolveCurrentPriceSource(
|
||||
return "inquire-price";
|
||||
}
|
||||
|
||||
function resolvePriceMarketDivCode(credentials?: KisCredentialInput) {
|
||||
return credentials?.tradingEnv === "mock" ? "J" : "UN";
|
||||
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 [...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;
|
||||
}
|
||||
|
||||
// ─── 차트 데이터 조회 메인 ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* 종목 차트 데이터 조회 (일봉/주봉/분봉)
|
||||
* - 일봉/주봉: inquire-daily-itemchartprice (FHKST03010100), 과거 페이징 지원
|
||||
* - 분봉: inquire-time-itemchartprice (FHKST03010200), **당일 데이터만** 제공
|
||||
*/
|
||||
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)
|
||||
.replaceAll("-", ""),
|
||||
-1,
|
||||
)
|
||||
: null;
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
const minuteBucket = minutesForTimeframe(timeframe);
|
||||
const candles = mergeCandlesByTimestamp(
|
||||
parseOutput2Rows(response)
|
||||
.map((row) => parseMinuteCandleRow(row, minuteBucket))
|
||||
.filter((c): c is StockCandlePoint => Boolean(c)),
|
||||
);
|
||||
|
||||
// 당일 분봉만 제공되므로 과거 페이징 불필요
|
||||
return { candles, hasMore: false, nextCursor: null };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user