대시보드 중간 커밋

This commit is contained in:
2026-02-10 11:16:39 +09:00
parent 851a2acd69
commit 105d75e1f8
52 changed files with 6826 additions and 1287 deletions

View File

@@ -0,0 +1,98 @@
import type {
DashboardChartTimeframe,
DashboardStockChartResponse,
} from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
"1m",
"30m",
"1h",
"1d",
"1w",
];
/**
* @file app/api/kis/domestic/chart/route.ts
* @description 국내주식 차트(분봉/일봉/주봉) 조회 API
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
const timeframe = (
searchParams.get("timeframe") ?? "1d"
).trim() as DashboardChartTimeframe;
const cursor = (searchParams.get("cursor") ?? "").trim() || undefined;
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json(
{ error: "symbol은 6자리 숫자여야 합니다." },
{ status: 400 },
);
}
if (!VALID_TIMEFRAMES.includes(timeframe)) {
return NextResponse.json(
{ error: "지원하지 않는 timeframe입니다." },
{ status: 400 },
);
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
},
{ status: 400 },
);
}
try {
const chart = await getDomesticChart(
symbol,
timeframe,
credentials,
cursor,
);
const response: DashboardStockChartResponse = {
symbol,
timeframe,
candles: chart.candles,
nextCursor: chart.nextCursor,
hasMore: chart.hasMore,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
const message =
error instanceof Error
? error.message
: "KIS 차트 조회 중 오류가 발생했습니다.";
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,
};
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { executeOrderCash } from "@/lib/kis/trade";
import {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/dashboard/types/dashboard.types";
import {
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
/**
* @file app/api/kis/domestic/order-cash/route.ts
* @description 국내주식 현금 주문 API
*/
export async function POST(request: NextRequest) {
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
try {
const body = (await request.json()) as DashboardStockCashOrderRequest;
// TODO: Validate body fields (symbol, quantity, price, etc.)
if (
!body.symbol ||
!body.accountNo ||
!body.accountProductCode ||
body.quantity <= 0
) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message:
"주문 정보가 올바르지 않습니다. (종목코드, 계좌번호, 수량 확인)",
},
{ status: 400 },
);
}
const output = await executeOrderCash(
{
symbol: body.symbol,
side: body.side,
orderType: body.orderType,
quantity: body.quantity,
price: body.price,
accountNo: body.accountNo,
accountProductCode: body.accountProductCode,
},
credentials,
);
const response: DashboardStockCashOrderResponse = {
ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "주문이 전송되었습니다.",
orderNo: output.ODNO,
orderTime: output.ORD_TMD,
orderOrgNo: output.KRX_FWDG_ORD_ORGNO,
};
return NextResponse.json(response);
} catch (error) {
const message =
error instanceof Error
? error.message
: "주문 전송 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
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,
};
}

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import {
getDomesticOrderBook,
KisDomesticOrderBookOutput,
} from "@/lib/kis/domestic";
import { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
import {
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
/**
* @file app/api/kis/domestic/orderbook/route.ts
* @description 국내주식 호가 조회 API
*/
export async function GET(request: NextRequest) {
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 raw = await getDomesticOrderBook(symbol, credentials);
const levels = Array.from({ length: 10 }, (_, i) => {
const idx = i + 1;
return {
askPrice: readOrderBookNumber(raw, `askp${idx}`),
bidPrice: readOrderBookNumber(raw, `bidp${idx}`),
askSize: readOrderBookNumber(raw, `askp_rsqn${idx}`),
bidSize: readOrderBookNumber(raw, `bidp_rsqn${idx}`),
};
});
const response: DashboardStockOrderBookResponse = {
symbol,
source: "kis",
levels,
totalAskSize: readOrderBookNumber(raw, "total_askp_rsqn"),
totalBidSize: readOrderBookNumber(raw, "total_bidp_rsqn"),
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,
};
}
/**
* @description 호가 응답 필드를 대소문자 모두 허용해 숫자로 읽습니다.
* @see app/api/kis/domestic/orderbook/route.ts GET에서 output/output1 키 차이 방어 로직으로 사용합니다.
*/
function readOrderBookNumber(raw: KisDomesticOrderBookOutput, key: string) {
const record = raw as Record<string, unknown>;
const direct = record[key];
const upper = record[key.toUpperCase()];
const value = direct ?? upper ?? "0";
const normalized =
typeof value === "string"
? value.replaceAll(",", "").trim()
: String(value ?? "0");
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : 0;
}

View File

@@ -1,4 +1,4 @@
import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks";
import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";