358 lines
10 KiB
TypeScript
358 lines
10 KiB
TypeScript
|
|
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;
|
|||
|
|
}
|