대시보드
This commit is contained in:
357
lib/kis/domestic.ts
Normal file
357
lib/kis/domestic.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import type { 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_clpr?: 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(credentials),
|
||||
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(credentials),
|
||||
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 ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재가 + 일봉을 대시보드 모델로 변환
|
||||
* @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 emptyCcnl: KisDomesticCcnlOutput = {};
|
||||
const emptyOvertime: KisDomesticOvertimePriceOutput = {};
|
||||
|
||||
const [quote, daily, ccnl, overtime] = await Promise.all([
|
||||
getDomesticQuote(symbol, credentials),
|
||||
getDomesticDailyPrice(symbol, credentials),
|
||||
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 ?? "",
|
||||
price: toNumber(row.stck_clpr),
|
||||
}))
|
||||
.filter((item) => item.date.length === 8 && item.price > 0)
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.slice(-20)
|
||||
.map((item) => ({
|
||||
time: formatDate(item.date),
|
||||
price: item.price,
|
||||
}));
|
||||
|
||||
if (parsed.length > 0) return parsed;
|
||||
|
||||
return [{ time: "오늘", price: Math.max(currentPrice, 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(credentials?: KisCredentialInput) {
|
||||
return credentials?.tradingEnv === "mock" ? "J" : "UN";
|
||||
}
|
||||
|
||||
function firstPositive(...values: number[]) {
|
||||
return values.find((value) => value > 0) ?? 0;
|
||||
}
|
||||
Reference in New Issue
Block a user