172 lines
5.2 KiB
TypeScript
172 lines
5.2 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import {
|
|
getDomesticOrderBook,
|
|
KisDomesticOrderBookOutput,
|
|
} from "@/lib/kis/domestic";
|
|
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
|
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
|
import {
|
|
KisCredentialInput,
|
|
hasKisConfig,
|
|
normalizeTradingEnv,
|
|
} from "@/lib/kis/config";
|
|
import {
|
|
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
|
parseDomesticKisSession,
|
|
} from "@/lib/kis/domestic-market-session";
|
|
|
|
/**
|
|
* @file app/api/kis/domestic/orderbook/route.ts
|
|
* @description 국내주식 호가 조회 API
|
|
*/
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const hasSession = await hasKisApiSession();
|
|
if (!hasSession) {
|
|
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
|
|
}
|
|
|
|
const { searchParams } = new URL(request.url);
|
|
const symbol = (searchParams.get("symbol") ?? "").trim();
|
|
|
|
if (!/^\d{6}$/.test(symbol)) {
|
|
return NextResponse.json(
|
|
{ error: "symbol은 6자리 숫자여야 합니다." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const credentials = readKisCredentialsFromHeaders(request.headers);
|
|
|
|
if (!hasKisConfig(credentials)) {
|
|
return NextResponse.json(
|
|
{
|
|
error: "KIS API 키 설정이 필요합니다.",
|
|
},
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
try {
|
|
const sessionOverride = readSessionOverrideFromHeaders(request.headers);
|
|
const raw = await getDomesticOrderBook(symbol, credentials, {
|
|
sessionOverride,
|
|
});
|
|
|
|
const levels = Array.from({ length: 10 }, (_, i) => {
|
|
const idx = i + 1;
|
|
return {
|
|
askPrice: readOrderBookNumber(raw, `askp${idx}`, `ovtm_untp_askp${idx}`),
|
|
bidPrice: readOrderBookNumber(raw, `bidp${idx}`, `ovtm_untp_bidp${idx}`),
|
|
askSize: readOrderBookNumber(
|
|
raw,
|
|
`askp_rsqn${idx}`,
|
|
`ovtm_untp_askp_rsqn${idx}`,
|
|
),
|
|
bidSize: readOrderBookNumber(raw, ...resolveBidSizeKeys(idx)),
|
|
};
|
|
});
|
|
|
|
const response: DashboardStockOrderBookResponse = {
|
|
symbol,
|
|
source: "kis",
|
|
levels,
|
|
totalAskSize: readOrderBookNumber(
|
|
raw,
|
|
"total_askp_rsqn",
|
|
"ovtm_untp_total_askp_rsqn",
|
|
"ovtm_total_askp_rsqn",
|
|
),
|
|
totalBidSize: readOrderBookNumber(
|
|
raw,
|
|
"total_bidp_rsqn",
|
|
"ovtm_untp_total_bidp_rsqn",
|
|
"ovtm_total_bidp_rsqn",
|
|
),
|
|
businessHour: readOrderBookString(raw, "bsop_hour", "ovtm_untp_last_hour"),
|
|
hourClassCode: readOrderBookString(raw, "hour_cls_code"),
|
|
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
|
fetchedAt: new Date().toISOString(),
|
|
};
|
|
|
|
return NextResponse.json(response, {
|
|
headers: {
|
|
"cache-control": "no-store",
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: "호가 조회 중 오류가 발생했습니다.";
|
|
return NextResponse.json({ error: message }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
|
|
const appKey = headers.get("x-kis-app-key")?.trim();
|
|
const appSecret = headers.get("x-kis-app-secret")?.trim();
|
|
const tradingEnv = normalizeTradingEnv(
|
|
headers.get("x-kis-trading-env") ?? undefined,
|
|
);
|
|
|
|
return {
|
|
appKey,
|
|
appSecret,
|
|
tradingEnv,
|
|
};
|
|
}
|
|
|
|
function readSessionOverrideFromHeaders(headers: Headers) {
|
|
if (process.env.NODE_ENV === "production") return null;
|
|
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
|
|
return parseDomesticKisSession(raw);
|
|
}
|
|
|
|
/**
|
|
* @description 호가 응답 필드를 대소문자 모두 허용해 숫자로 읽습니다.
|
|
* @see app/api/kis/domestic/orderbook/route.ts GET에서 output/output1 키 차이 방어 로직으로 사용합니다.
|
|
*/
|
|
function readOrderBookNumber(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
|
|
const record = raw as Record<string, unknown>;
|
|
const value = resolveOrderBookValue(record, keys) ?? "0";
|
|
const normalized =
|
|
typeof value === "string"
|
|
? value.replaceAll(",", "").trim()
|
|
: String(value ?? "0");
|
|
const parsed = Number(normalized);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
|
|
/**
|
|
* @description 호가 응답 필드를 문자열로 읽습니다.
|
|
* @see app/api/kis/domestic/orderbook/route.ts GET 응답 생성 시 businessHour/hourClassCode 추출
|
|
*/
|
|
function readOrderBookString(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
|
|
const record = raw as Record<string, unknown>;
|
|
const value = resolveOrderBookValue(record, keys);
|
|
if (value === undefined || value === null) return undefined;
|
|
const text = String(value).trim();
|
|
return text.length > 0 ? text : undefined;
|
|
}
|
|
|
|
function resolveOrderBookValue(record: Record<string, unknown>, keys: string[]) {
|
|
for (const key of keys) {
|
|
const direct = record[key];
|
|
if (direct !== undefined && direct !== null) return direct;
|
|
|
|
const upper = record[key.toUpperCase()];
|
|
if (upper !== undefined && upper !== null) return upper;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function resolveBidSizeKeys(index: number) {
|
|
if (index === 2) {
|
|
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`, "ovtm_untp_bidp_rsqn"];
|
|
}
|
|
|
|
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`];
|
|
}
|