전체적인 리팩토링

This commit is contained in:
2026-03-12 09:26:27 +09:00
parent 406af7408a
commit e51d767878
97 changed files with 13651 additions and 363 deletions

View File

@@ -0,0 +1,227 @@
import { NextResponse } from "next/server";
import { hasKisConfig } from "@/lib/kis/config";
import {
readKisAccountParts,
readKisCredentialsFromHeaders,
} from "@/app/api/kis/domestic/_shared";
import type {
AutotradeSessionInfo,
AutotradeStopReason,
} from "@/features/autotrade/types/autotrade.types";
import { createClient } from "@/utils/supabase/server";
export const AUTOTRADE_DEV_BYPASS_HEADER = "x-autotrade-dev-bypass";
export const AUTOTRADE_WORKER_TOKEN_HEADER = "x-autotrade-worker-token";
export const AUTOTRADE_API_ERROR_CODE = {
AUTH_REQUIRED: "AUTOTRADE_AUTH_REQUIRED",
INVALID_REQUEST: "AUTOTRADE_INVALID_REQUEST",
CREDENTIAL_REQUIRED: "AUTOTRADE_CREDENTIAL_REQUIRED",
SESSION_NOT_FOUND: "AUTOTRADE_SESSION_NOT_FOUND",
CONFLICT: "AUTOTRADE_CONFLICT",
INTERNAL: "AUTOTRADE_INTERNAL",
} as const;
export type AutotradeApiErrorCode =
(typeof AUTOTRADE_API_ERROR_CODE)[keyof typeof AUTOTRADE_API_ERROR_CODE];
export interface AutotradeSessionRecord extends AutotradeSessionInfo {
userId: string;
strategySummary: string;
}
declare global {
var __autotradeSessionMap: Map<string, AutotradeSessionRecord> | undefined;
}
function getSessionMap() {
if (!globalThis.__autotradeSessionMap) {
globalThis.__autotradeSessionMap = new Map<string, AutotradeSessionRecord>();
}
return globalThis.__autotradeSessionMap;
}
export function createAutotradeErrorResponse(options: {
status: number;
code: AutotradeApiErrorCode;
message: string;
extra?: Record<string, unknown>;
}) {
return NextResponse.json(
{
ok: false,
errorCode: options.code,
message: options.message,
...(options.extra ?? {}),
},
{ status: options.status },
);
}
export async function getAutotradeUserId(headers?: Headers) {
if (isAutotradeDevBypass(headers)) {
return "dev-autotrade-user";
}
const supabase = await createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) return null;
return user.id;
}
export async function readJsonBody(request: Request) {
const text = await request.text();
if (!text.trim()) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
export function hasAutotradeKisRuntimeHeaders(headers: Headers) {
if (isAutotradeDevBypass(headers)) {
return true;
}
const credentials = readKisCredentialsFromHeaders(headers);
const account = readKisAccountParts(headers);
return Boolean(hasKisConfig(credentials) && account);
}
export function upsertAutotradeSession(record: AutotradeSessionRecord) {
const map = getSessionMap();
map.set(record.userId, record);
return record;
}
export function getAutotradeSession(userId: string) {
const map = getSessionMap();
const record = map.get(userId) ?? null;
if (!record) return null;
if (record.runtimeState === "RUNNING" && isHeartbeatExpired(record.lastHeartbeatAt)) {
const stoppedRecord = {
...record,
runtimeState: "STOPPED" as const,
stopReason: "heartbeat_timeout" as const,
endedAt: new Date().toISOString(),
};
map.set(userId, stoppedRecord);
return stoppedRecord;
}
return record;
}
export function listAutotradeSessions() {
return Array.from(getSessionMap().values()).sort((a, b) =>
b.startedAt.localeCompare(a.startedAt),
);
}
export function stopAutotradeSession(userId: string, reason: AutotradeStopReason) {
const map = getSessionMap();
const record = map.get(userId);
if (!record) return null;
const stoppedRecord: AutotradeSessionRecord = {
...record,
runtimeState: "STOPPED",
stopReason: reason,
endedAt: new Date().toISOString(),
lastHeartbeatAt: new Date().toISOString(),
};
map.set(userId, stoppedRecord);
return stoppedRecord;
}
export function sweepExpiredAutotradeSessions() {
const map = getSessionMap();
let expiredCount = 0;
for (const [userId, record] of map.entries()) {
if (record.runtimeState !== "RUNNING") continue;
if (!isHeartbeatExpired(record.lastHeartbeatAt)) continue;
const stoppedRecord: AutotradeSessionRecord = {
...record,
runtimeState: "STOPPED",
stopReason: "heartbeat_timeout",
endedAt: new Date().toISOString(),
lastHeartbeatAt: new Date().toISOString(),
};
map.set(userId, stoppedRecord);
expiredCount += 1;
}
return {
totalSessionCount: map.size,
expiredCount,
};
}
export function getAutotradeHeartbeatTtlSec() {
const parsed = Number.parseInt(process.env.AUTOTRADE_HEARTBEAT_TTL_SEC ?? "90", 10);
if (!Number.isFinite(parsed)) return 90;
return Math.min(300, Math.max(30, parsed));
}
export function isHeartbeatExpired(lastHeartbeatAt: string) {
const lastHeartbeatMs = new Date(lastHeartbeatAt).getTime();
if (!Number.isFinite(lastHeartbeatMs)) return true;
return Date.now() - lastHeartbeatMs > getAutotradeHeartbeatTtlSec() * 1000;
}
export function sanitizeAutotradeError(error: unknown, fallback: string) {
const message = error instanceof Error ? error.message : fallback;
return maskSensitiveTokens(message) || fallback;
}
export function maskSensitiveTokens(value: string) {
return value
.replace(/([A-Za-z0-9]{4})[A-Za-z0-9]{8,}([A-Za-z0-9]{4})/g, "$1********$2")
.replace(/(x-kis-app-secret\s*[:=]\s*)([^\s]+)/gi, "$1********")
.replace(/(x-kis-app-key\s*[:=]\s*)([^\s]+)/gi, "$1********");
}
export function isAutotradeWorkerAuthorized(headers: Headers) {
const providedToken = headers.get(AUTOTRADE_WORKER_TOKEN_HEADER)?.trim();
if (!providedToken) return false;
const expectedToken = process.env.AUTOTRADE_WORKER_TOKEN?.trim();
if (expectedToken) {
return providedToken === expectedToken;
}
// 운영 환경에서는 토큰 미설정 상태를 허용하지 않습니다.
if (process.env.NODE_ENV === "production") {
return false;
}
return providedToken === "autotrade-worker-local";
}
function isAutotradeDevBypass(headers?: Headers) {
if (!headers || process.env.NODE_ENV === "production") {
return false;
}
const providedToken = headers.get(AUTOTRADE_DEV_BYPASS_HEADER)?.trim();
if (!providedToken) return false;
const expectedToken =
process.env.AUTOTRADE_DEV_BYPASS_TOKEN?.trim() || "autotrade-dev-bypass";
return providedToken === expectedToken;
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import {
AUTOTRADE_API_ERROR_CODE,
createAutotradeErrorResponse,
getAutotradeSession,
getAutotradeUserId,
} from "@/app/api/autotrade/_shared";
export async function GET(request: Request) {
const userId = await getAutotradeUserId(request.headers);
if (!userId) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const session = getAutotradeSession(userId);
return NextResponse.json({
ok: true,
session: session && session.runtimeState === "RUNNING" ? session : null,
});
}

View File

@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
AUTOTRADE_API_ERROR_CODE,
createAutotradeErrorResponse,
getAutotradeSession,
getAutotradeUserId,
readJsonBody,
sanitizeAutotradeError,
upsertAutotradeSession,
} from "@/app/api/autotrade/_shared";
const heartbeatRequestSchema = z.object({
sessionId: z.string().uuid(),
leaderTabId: z.string().trim().min(1).max(100).optional(),
});
export async function POST(request: Request) {
const userId = await getAutotradeUserId(request.headers);
if (!userId) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const rawBody = await readJsonBody(request);
const parsed = heartbeatRequestSchema.safeParse(rawBody);
if (!parsed.success) {
return createAutotradeErrorResponse({
status: 400,
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
message: parsed.error.issues[0]?.message ?? "heartbeat 요청값이 올바르지 않습니다.",
});
}
try {
const session = getAutotradeSession(userId);
if (!session || session.runtimeState !== "RUNNING") {
return createAutotradeErrorResponse({
status: 404,
code: AUTOTRADE_API_ERROR_CODE.SESSION_NOT_FOUND,
message: "실행 중인 자동매매 세션이 없습니다.",
});
}
if (session.sessionId !== parsed.data.sessionId) {
return createAutotradeErrorResponse({
status: 409,
code: AUTOTRADE_API_ERROR_CODE.CONFLICT,
message: "세션 식별자가 일치하지 않습니다.",
});
}
const updated = upsertAutotradeSession({
...session,
lastHeartbeatAt: new Date().toISOString(),
leaderTabId: parsed.data.leaderTabId ?? session.leaderTabId,
});
return NextResponse.json({
ok: true,
session: updated,
});
} catch (error) {
return createAutotradeErrorResponse({
status: 500,
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
message: sanitizeAutotradeError(error, "heartbeat 처리 중 오류가 발생했습니다."),
});
}
}

View File

@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
AUTOTRADE_API_ERROR_CODE,
createAutotradeErrorResponse,
getAutotradeUserId,
hasAutotradeKisRuntimeHeaders,
readJsonBody,
sanitizeAutotradeError,
upsertAutotradeSession,
} from "@/app/api/autotrade/_shared";
const startRequestSchema = z.object({
symbol: z.string().trim().regex(/^\d{6}$/),
leaderTabId: z.string().trim().min(1).max(100),
effectiveAllocationAmount: z.number().int().positive(),
effectiveDailyLossLimit: z.number().int().positive(),
strategySummary: z.string().trim().min(1).max(320),
});
export async function POST(request: Request) {
const userId = await getAutotradeUserId(request.headers);
if (!userId) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
if (!hasAutotradeKisRuntimeHeaders(request.headers)) {
return createAutotradeErrorResponse({
status: 400,
code: AUTOTRADE_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "자동매매 시작에는 KIS 인증 헤더가 필요합니다.",
});
}
const rawBody = await readJsonBody(request);
const parsed = startRequestSchema.safeParse(rawBody);
if (!parsed.success) {
return createAutotradeErrorResponse({
status: 400,
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
message: parsed.error.issues[0]?.message ?? "세션 시작 입력값이 올바르지 않습니다.",
});
}
try {
const now = new Date().toISOString();
const session = upsertAutotradeSession({
userId,
sessionId: crypto.randomUUID(),
symbol: parsed.data.symbol,
runtimeState: "RUNNING",
leaderTabId: parsed.data.leaderTabId,
startedAt: now,
lastHeartbeatAt: now,
endedAt: null,
stopReason: null,
effectiveAllocationAmount: parsed.data.effectiveAllocationAmount,
effectiveDailyLossLimit: parsed.data.effectiveDailyLossLimit,
strategySummary: parsed.data.strategySummary,
});
return NextResponse.json({
ok: true,
session,
});
} catch (error) {
return createAutotradeErrorResponse({
status: 500,
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
message: sanitizeAutotradeError(error, "자동매매 세션 시작 중 오류가 발생했습니다."),
});
}
}

View File

@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
AUTOTRADE_API_ERROR_CODE,
createAutotradeErrorResponse,
getAutotradeSession,
getAutotradeUserId,
readJsonBody,
sanitizeAutotradeError,
stopAutotradeSession,
} from "@/app/api/autotrade/_shared";
import type { AutotradeStopReason } from "@/features/autotrade/types/autotrade.types";
const stopRequestSchema = z.object({
sessionId: z.string().uuid().optional(),
reason: z
.enum([
"browser_exit",
"external_leave",
"manual",
"emergency",
"heartbeat_timeout",
])
.optional(),
});
export async function POST(request: Request) {
const userId = await getAutotradeUserId(request.headers);
if (!userId) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const rawBody = await readJsonBody(request);
const parsed = stopRequestSchema.safeParse(rawBody ?? {});
if (!parsed.success) {
return createAutotradeErrorResponse({
status: 400,
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
message: parsed.error.issues[0]?.message ?? "세션 종료 입력값이 올바르지 않습니다.",
});
}
try {
const session = getAutotradeSession(userId);
if (!session) {
return NextResponse.json({
ok: true,
session: null,
});
}
if (parsed.data.sessionId && parsed.data.sessionId !== session.sessionId) {
return createAutotradeErrorResponse({
status: 409,
code: AUTOTRADE_API_ERROR_CODE.CONFLICT,
message: "세션 식별자가 일치하지 않습니다.",
});
}
const reason: AutotradeStopReason = parsed.data.reason ?? "manual";
const stopped = stopAutotradeSession(userId, reason);
return NextResponse.json({
ok: true,
session: stopped,
});
} catch (error) {
return createAutotradeErrorResponse({
status: 500,
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
message: sanitizeAutotradeError(error, "세션 종료 중 오류가 발생했습니다."),
});
}
}

View File

@@ -0,0 +1,440 @@
/**
* [파일 역할]
* 컴파일된 전략 + 시세 스냅샷으로 매수/매도/대기 신호를 생성하는 API 라우트입니다.
*
* [주요 책임]
* - 요청 검증(strategy/snapshot)
* - provider 분기(OpenAI/구독형 CLI/fallback)
* - 실패 시 fallback 신호로 대체
*/
import { NextResponse } from "next/server";
import { z } from "zod";
import {
AUTOTRADE_API_ERROR_CODE,
createAutotradeErrorResponse,
getAutotradeUserId,
readJsonBody,
sanitizeAutotradeError,
} from "@/app/api/autotrade/_shared";
import { AUTOTRADE_TECHNIQUE_IDS } from "@/features/autotrade/types/autotrade.types";
import {
generateSignalWithSubscriptionCliDetailed,
summarizeSubscriptionCliExecution,
} from "@/lib/autotrade/cli-provider";
import { generateSignalWithOpenAi, isOpenAiConfigured } from "@/lib/autotrade/openai";
import { createFallbackSignalCandidate } from "@/lib/autotrade/strategy";
export const runtime = "nodejs";
const strategySchema = z.object({
provider: z.enum(["openai", "fallback", "subscription_cli"]),
summary: z.string().trim().min(1).max(320),
selectedTechniques: z.array(z.enum(AUTOTRADE_TECHNIQUE_IDS)).default([]),
confidenceThreshold: z.number().min(0.45).max(0.95),
maxDailyOrders: z.number().int().min(1).max(200),
cooldownSec: z.number().int().min(10).max(600),
maxOrderAmountRatio: z.number().min(0.05).max(1),
createdAt: z.string().trim().min(1),
});
const signalRequestSchema = z.object({
aiMode: z
.enum(["auto", "openai_api", "subscription_cli", "rule_fallback"])
.default("auto"),
subscriptionCliVendor: z.enum(["auto", "codex", "gemini"]).optional(),
subscriptionCliModel: z.string().trim().max(80).optional(),
prompt: z.string().trim().max(1200).default(""),
strategy: strategySchema,
snapshot: z.object({
symbol: z.string().trim().regex(/^\d{6}$/),
stockName: z.string().trim().max(120).optional(),
market: z.enum(["KOSPI", "KOSDAQ"]).optional(),
requestAtIso: z.string().trim().max(40).optional(),
requestAtKst: z.string().trim().max(40).optional(),
tickTime: z.string().trim().max(12).optional(),
executionClassCode: z.string().trim().max(10).optional(),
isExpected: z.boolean().optional(),
trId: z.string().trim().max(32).optional(),
currentPrice: z.number().positive(),
prevClose: z.number().nonnegative().optional(),
changeRate: z.number(),
open: z.number().nonnegative(),
high: z.number().nonnegative(),
low: z.number().nonnegative(),
tradeVolume: z.number().nonnegative(),
accumulatedVolume: z.number().nonnegative(),
tradeStrength: z.number().optional(),
askPrice1: z.number().nonnegative().optional(),
bidPrice1: z.number().nonnegative().optional(),
askSize1: z.number().nonnegative().optional(),
bidSize1: z.number().nonnegative().optional(),
totalAskSize: z.number().nonnegative().optional(),
totalBidSize: z.number().nonnegative().optional(),
buyExecutionCount: z.number().int().optional(),
sellExecutionCount: z.number().int().optional(),
netBuyExecutionCount: z.number().int().optional(),
spread: z.number().nonnegative().optional(),
spreadRate: z.number().optional(),
dayRangePercent: z.number().nonnegative().optional(),
dayRangePosition: z.number().min(0).max(1).optional(),
volumeRatio: z.number().nonnegative().optional(),
recentTradeCount: z.number().int().nonnegative().optional(),
recentTradeVolumeSum: z.number().nonnegative().optional(),
recentAverageTradeVolume: z.number().nonnegative().optional(),
accumulatedVolumeDelta: z.number().nonnegative().optional(),
netBuyExecutionDelta: z.number().optional(),
orderBookImbalance: z.number().min(-1).max(1).optional(),
liquidityDepth: z.number().nonnegative().optional(),
topLevelOrderBookImbalance: z.number().min(-1).max(1).optional(),
buySellExecutionRatio: z.number().nonnegative().optional(),
recentPriceHigh: z.number().positive().optional(),
recentPriceLow: z.number().positive().optional(),
recentPriceRangePercent: z.number().nonnegative().optional(),
recentTradeVolumes: z.array(z.number().nonnegative()).max(20).optional(),
recentNetBuyTrail: z.array(z.number()).max(20).optional(),
recentTickAgesSec: z.array(z.number().nonnegative()).max(20).optional(),
intradayMomentum: z.number().optional(),
recentReturns: z.array(z.number()).max(12).optional(),
recentPrices: z.array(z.number().positive()).min(3).max(30),
marketDataLatencySec: z.number().nonnegative().optional(),
recentMinuteCandles: z
.array(
z.object({
time: z.string().trim().max(32),
open: z.number().positive(),
high: z.number().positive(),
low: z.number().positive(),
close: z.number().positive(),
volume: z.number().nonnegative(),
timestamp: z.number().int().optional(),
}),
)
.max(30)
.optional(),
minutePatternContext: z
.object({
timeframe: z.literal("1m"),
candleCount: z.number().int().min(1).max(30),
impulseDirection: z.enum(["up", "down", "flat"]),
impulseBarCount: z.number().int().min(1).max(20),
consolidationBarCount: z.number().int().min(1).max(12),
impulseChangeRate: z.number().optional(),
impulseRangePercent: z.number().nonnegative().optional(),
consolidationRangePercent: z.number().nonnegative().optional(),
consolidationCloseClusterPercent: z.number().nonnegative().optional(),
consolidationVolumeRatio: z.number().nonnegative().optional(),
breakoutUpper: z.number().positive().optional(),
breakoutLower: z.number().positive().optional(),
})
.optional(),
budgetContext: z
.object({
setupAllocationPercent: z.number().nonnegative(),
setupAllocationAmount: z.number().nonnegative(),
effectiveAllocationAmount: z.number().nonnegative(),
strategyMaxOrderAmountRatio: z.number().min(0).max(1),
effectiveOrderBudgetAmount: z.number().nonnegative(),
estimatedBuyUnitCost: z.number().nonnegative(),
estimatedBuyableQuantity: z.number().int().nonnegative(),
})
.optional(),
portfolioContext: z
.object({
holdingQuantity: z.number().int().nonnegative(),
sellableQuantity: z.number().int().nonnegative(),
averagePrice: z.number().nonnegative(),
estimatedSellableNetAmount: z.number().optional(),
})
.optional(),
executionCostProfile: z
.object({
buyFeeRate: z.number().nonnegative(),
sellFeeRate: z.number().nonnegative(),
sellTaxRate: z.number().nonnegative(),
})
.optional(),
}),
});
const signalResultSchema = z.object({
signal: z.enum(["buy", "sell", "hold"]),
confidence: z.number().min(0).max(1),
reason: z.string().min(1).max(160),
ttlSec: z.number().int().min(5).max(300),
riskFlags: z.array(z.string()).max(10).default([]),
proposedOrder: z
.object({
symbol: z.string().trim().regex(/^\d{6}$/),
side: z.enum(["buy", "sell"]),
orderType: z.enum(["limit", "market"]),
price: z.number().positive().optional(),
quantity: z.number().int().positive().optional(),
})
.optional(),
});
export async function POST(request: Request) {
const userId = await getAutotradeUserId(request.headers);
if (!userId) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const rawBody = await readJsonBody(request);
const parsed = signalRequestSchema.safeParse(rawBody);
if (!parsed.success) {
return createAutotradeErrorResponse({
status: 400,
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
message: parsed.error.issues[0]?.message ?? "신호 생성 요청값이 올바르지 않습니다.",
});
}
try {
// [Step 1] 안전망: 우선 규칙 기반 fallback 신호를 준비합니다.
const fallbackSignal = createFallbackSignalCandidate({
strategy: parsed.data.strategy,
snapshot: parsed.data.snapshot,
});
// [Step 2] 규칙 기반 강제 모드
if (parsed.data.aiMode === "rule_fallback") {
return NextResponse.json({
ok: true,
signal: fallbackSignal,
});
}
// [Step 3] OpenAI 모드(auto/openai_api): 성공 시 해당 신호를 그대로 반환
const shouldUseOpenAi = parsed.data.aiMode === "openai_api" || parsed.data.aiMode === "auto";
if (shouldUseOpenAi && isOpenAiConfigured()) {
const aiSignal = await generateSignalWithOpenAi({
prompt: parsed.data.prompt,
strategy: parsed.data.strategy,
snapshot: parsed.data.snapshot,
});
if (aiSignal) {
const localizedReason = ensureKoreanReason(aiSignal.reason, aiSignal.signal);
return NextResponse.json({
ok: true,
signal: {
...aiSignal,
reason: localizedReason,
},
});
}
}
// [Step 4] 구독형 CLI 모드(subscription_cli/auto-fallback): codex/gemini CLI 자동판단
const shouldUseCli =
parsed.data.aiMode === "subscription_cli" ||
(parsed.data.aiMode === "auto" && !isOpenAiConfigured());
if (shouldUseCli) {
const cliResult = await generateSignalWithSubscriptionCliDetailed({
prompt: parsed.data.prompt,
strategy: parsed.data.strategy,
snapshot: parsed.data.snapshot,
preferredVendor: parsed.data.subscriptionCliVendor,
preferredModel:
parsed.data.subscriptionCliVendor && parsed.data.subscriptionCliVendor !== "auto"
? parsed.data.subscriptionCliModel
: undefined,
});
const normalizedCliSignal = normalizeCliSignalCandidate(
cliResult.parsed,
parsed.data.snapshot.symbol,
);
const cliParsed = signalResultSchema.safeParse(normalizedCliSignal);
if (cliParsed.success) {
const localizedReason = ensureKoreanReason(
cliParsed.data.reason,
cliParsed.data.signal,
);
return NextResponse.json({
ok: true,
signal: {
...cliParsed.data,
reason: localizedReason,
source: "subscription_cli",
providerVendor: cliResult.vendor ?? undefined,
providerModel: cliResult.model ?? undefined,
},
});
}
const cliExecutionSummary = summarizeSubscriptionCliExecution(cliResult);
return NextResponse.json({
ok: true,
signal: {
...fallbackSignal,
// CLI 응답이 비정상이어도 주문 엔진이 멈추지 않도록 fallback 신호로 대체합니다.
reason: `구독형 CLI 응답을 해석하지 못해 규칙 기반 신호로 대체했습니다. (${cliExecutionSummary})`,
providerVendor: cliResult.vendor ?? undefined,
providerModel: cliResult.model ?? undefined,
},
});
}
return NextResponse.json({
ok: true,
signal: fallbackSignal,
});
} catch (error) {
return createAutotradeErrorResponse({
status: 500,
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
message: sanitizeAutotradeError(error, "신호 생성 중 오류가 발생했습니다."),
});
}
}
function normalizeCliSignalCandidate(raw: unknown, defaultSymbol: string) {
const source = resolveSignalPayloadSource(raw);
if (!source) return raw;
const signal = normalizeSignalValue(source.signal ?? source.action ?? source.side);
const confidence = clampNumber(source.confidence ?? source.score ?? source.probability, 0, 1);
const reason = normalizeReasonText(source.reason ?? source.rationale ?? source.comment);
const ttlSec = normalizeInteger(source.ttlSec ?? source.ttl, 20, 5, 300);
const riskFlags = normalizeRiskFlags(source.riskFlags ?? source.risks);
const proposedOrder = normalizeProposedOrder(source.proposedOrder ?? source.order, defaultSymbol);
return {
signal: signal ?? source.signal,
confidence,
reason,
ttlSec,
riskFlags,
proposedOrder,
};
}
function resolveSignalPayloadSource(raw: unknown): Record<string, unknown> | null {
if (!raw || typeof raw !== "object") return null;
const source = raw as Record<string, unknown>;
if (source.signal || source.action || source.side || source.proposedOrder || source.order) {
return source;
}
const nestedCandidate =
source.decision ??
source.result ??
source.data ??
source.output ??
source.payload;
if (!nestedCandidate || typeof nestedCandidate !== "object") {
return null;
}
return nestedCandidate as Record<string, unknown>;
}
function normalizeSignalValue(raw: unknown) {
if (typeof raw !== "string") return null;
const normalized = raw.trim().toLowerCase();
if (normalized === "buy" || normalized === "sell" || normalized === "hold") {
return normalized;
}
return null;
}
function clampNumber(raw: unknown, min: number, max: number) {
const value = typeof raw === "number" ? raw : Number.parseFloat(String(raw ?? ""));
if (!Number.isFinite(value)) return 0.5;
return Math.min(max, Math.max(min, value));
}
function normalizeInteger(raw: unknown, fallback: number, min: number, max: number) {
const value = Number.parseInt(String(raw ?? ""), 10);
if (!Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, value));
}
function normalizeReasonText(raw: unknown) {
const value = typeof raw === "string" ? raw.trim() : "";
if (!value) return "신호 사유가 없어 hold로 처리했습니다.";
return value.slice(0, 160);
}
function ensureKoreanReason(
reason: string,
signal: "buy" | "sell" | "hold",
) {
const normalized = normalizeReasonText(reason);
if (/[가-힣]/.test(normalized)) {
return normalized;
}
if (signal === "buy") {
return "상승 신호가 확인되어 매수 관점으로 판단했습니다.";
}
if (signal === "sell") {
return "하락 또는 과열 신호가 확인되어 매도 관점으로 판단했습니다.";
}
return "명확한 방향성이 부족해 대기 신호로 판단했습니다.";
}
function normalizeRiskFlags(raw: unknown) {
if (Array.isArray(raw)) {
return raw
.map((item) => String(item).trim())
.filter((item) => item.length > 0)
.slice(0, 10);
}
if (typeof raw === "string") {
return raw
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0)
.slice(0, 10);
}
return [];
}
function normalizeProposedOrder(raw: unknown, defaultSymbol: string) {
if (!raw || typeof raw !== "object") return undefined;
const source = raw as Record<string, unknown>;
const side = normalizeSignalValue(source.side);
if (side !== "buy" && side !== "sell") return undefined;
const orderTypeRaw = String(source.orderType ?? source.type ?? "limit")
.trim()
.toLowerCase();
const orderType = orderTypeRaw === "market" ? "market" : "limit";
const symbolRaw = String(source.symbol ?? defaultSymbol).trim();
const symbol = /^\d{6}$/.test(symbolRaw) ? symbolRaw : defaultSymbol;
const price = parseOptionalPositiveNumber(source.price);
const quantity = parseOptionalPositiveInteger(source.quantity ?? source.qty);
return {
symbol,
side,
orderType,
price,
quantity,
};
}
function parseOptionalPositiveNumber(raw: unknown) {
if (raw === undefined || raw === null || raw === "") return undefined;
const value = typeof raw === "number" ? raw : Number.parseFloat(String(raw));
if (!Number.isFinite(value) || value <= 0) return undefined;
return value;
}
function parseOptionalPositiveInteger(raw: unknown) {
if (raw === undefined || raw === null || raw === "") return undefined;
const value = Number.parseInt(String(raw), 10);
if (!Number.isFinite(value) || value <= 0) return undefined;
return value;
}

View File

@@ -0,0 +1,411 @@
/**
* [파일 역할]
* 전략 프롬프트를 실행 가능한 자동매매 전략(JSON)으로 컴파일하는 API 라우트입니다.
*
* [주요 책임]
* - 요청 검증(aiMode/prompt/기법/신뢰도)
* - provider 분기(OpenAI/구독형 CLI/fallback)
* - 실패 시 fallback 전략으로 안전하게 응답
*/
import { NextResponse } from "next/server";
import { z } from "zod";
import {
AUTOTRADE_API_ERROR_CODE,
createAutotradeErrorResponse,
getAutotradeUserId,
readJsonBody,
sanitizeAutotradeError,
} from "@/app/api/autotrade/_shared";
import {
AUTOTRADE_DEFAULT_TECHNIQUES,
AUTOTRADE_TECHNIQUE_IDS,
} from "@/features/autotrade/types/autotrade.types";
import {
compileStrategyWithSubscriptionCliDetailed,
summarizeSubscriptionCliExecution,
} from "@/lib/autotrade/cli-provider";
import { compileStrategyWithOpenAi, isOpenAiConfigured } from "@/lib/autotrade/openai";
import { createFallbackCompiledStrategy } from "@/lib/autotrade/strategy";
export const runtime = "nodejs";
const compileRequestSchema = z.object({
aiMode: z
.enum(["auto", "openai_api", "subscription_cli", "rule_fallback"])
.default("auto"),
subscriptionCliVendor: z.enum(["auto", "codex", "gemini"]).optional(),
subscriptionCliModel: z.string().trim().max(80).optional(),
prompt: z.string().trim().max(1200).default(""),
selectedTechniques: z.array(z.enum(AUTOTRADE_TECHNIQUE_IDS)).default([]),
confidenceThreshold: z.number().min(0.45).max(0.95).optional(),
});
const compileResultSchema = z.object({
summary: z.string().min(1).max(320),
confidenceThreshold: z.number().min(0.45).max(0.95),
maxDailyOrders: z.number().int().min(1).max(200),
cooldownSec: z.number().int().min(10).max(600),
maxOrderAmountRatio: z.number().min(0.05).max(1),
});
export async function POST(request: Request) {
const userId = await getAutotradeUserId(request.headers);
if (!userId) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const rawBody = await readJsonBody(request);
const parsed = compileRequestSchema.safeParse(rawBody);
if (!parsed.success) {
return createAutotradeErrorResponse({
status: 400,
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
message: parsed.error.issues[0]?.message ?? "전략 입력값이 올바르지 않습니다.",
});
}
try {
const selectedTechniques =
parsed.data.selectedTechniques.length > 0
? parsed.data.selectedTechniques
: AUTOTRADE_DEFAULT_TECHNIQUES;
// [Step 1] 어떤 모드든 공통 최소전략(규칙 기반)을 먼저 준비해 둡니다.
const fallback = createFallbackCompiledStrategy({
prompt: parsed.data.prompt,
selectedTechniques,
confidenceThreshold: parsed.data.confidenceThreshold ?? 0.65,
});
// [Step 2] 규칙 기반 강제 모드는 즉시 fallback 전략으로 반환합니다.
if (parsed.data.aiMode === "rule_fallback") {
return NextResponse.json({
ok: true,
compiledStrategy: {
...fallback,
summary: `규칙 기반 모드: ${fallback.summary}`,
},
});
}
// [Step 3] OpenAI 모드(auto/openai_api): API 키가 있으면 OpenAI 결과를 우선 사용합니다.
const shouldUseOpenAi = parsed.data.aiMode === "auto" || parsed.data.aiMode === "openai_api";
if (shouldUseOpenAi && isOpenAiConfigured()) {
const aiResult = await compileStrategyWithOpenAi({
prompt: parsed.data.prompt,
selectedTechniques,
confidenceThreshold: fallback.confidenceThreshold,
});
if (aiResult) {
const finalizedSummary = finalizeCompiledSummary({
summary: aiResult.summary,
prompt: parsed.data.prompt,
selectedTechniques,
});
return NextResponse.json({
ok: true,
compiledStrategy: {
...fallback,
provider: "openai",
summary: finalizedSummary,
confidenceThreshold: aiResult.confidenceThreshold,
maxDailyOrders: aiResult.maxDailyOrders,
cooldownSec: aiResult.cooldownSec,
maxOrderAmountRatio: aiResult.maxOrderAmountRatio,
createdAt: new Date().toISOString(),
},
});
}
}
// [Step 4] 구독형 CLI 모드(subscription_cli/auto-fallback): codex/gemini CLI를 호출합니다.
const shouldUseCli =
parsed.data.aiMode === "subscription_cli" ||
(parsed.data.aiMode === "auto" && !isOpenAiConfigured());
if (shouldUseCli) {
const cliResult = await compileStrategyWithSubscriptionCliDetailed({
prompt: parsed.data.prompt,
selectedTechniques,
confidenceThreshold: fallback.confidenceThreshold,
preferredVendor: parsed.data.subscriptionCliVendor,
preferredModel:
parsed.data.subscriptionCliVendor && parsed.data.subscriptionCliVendor !== "auto"
? parsed.data.subscriptionCliModel
: undefined,
});
const normalizedCliCompile = normalizeCliCompileResult(cliResult.parsed, fallback);
const cliParsed = compileResultSchema.safeParse(normalizedCliCompile);
if (cliParsed.success) {
const finalizedSummary = finalizeCompiledSummary({
summary: cliParsed.data.summary,
prompt: parsed.data.prompt,
selectedTechniques,
});
return NextResponse.json({
ok: true,
compiledStrategy: {
...fallback,
provider: "subscription_cli",
providerVendor: cliResult.vendor ?? undefined,
providerModel: cliResult.model ?? undefined,
summary: finalizedSummary,
confidenceThreshold: cliParsed.data.confidenceThreshold,
maxDailyOrders: cliParsed.data.maxDailyOrders,
cooldownSec: cliParsed.data.cooldownSec,
maxOrderAmountRatio: cliParsed.data.maxOrderAmountRatio,
createdAt: new Date().toISOString(),
},
});
}
const parseSummary = summarizeCompileParseFailure(cliResult.parsed);
return NextResponse.json({
ok: true,
compiledStrategy: {
...fallback,
provider: "subscription_cli",
providerVendor: cliResult.vendor ?? undefined,
providerModel: cliResult.model ?? undefined,
// CLI가 실패해도 자동매매가 멈추지 않도록 fallback 전략으로 안전하게 유지합니다.
summary: `구독형 CLI 응답을 해석하지 못해 규칙 기반 전략으로 동작합니다. (${summarizeSubscriptionCliExecution(cliResult)}; parse=${parseSummary})`,
},
});
}
return NextResponse.json({
ok: true,
compiledStrategy: fallback,
});
} catch (error) {
return createAutotradeErrorResponse({
status: 500,
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
message: sanitizeAutotradeError(error, "전략 컴파일 중 오류가 발생했습니다."),
});
}
}
function normalizeCliCompileResult(raw: unknown, fallback: ReturnType<typeof createFallbackCompiledStrategy>) {
const source = resolveCompilePayloadSource(raw);
if (!source) return raw;
const summary = normalizeSummaryText(
source.summary ??
source.strategySummary ??
source.description ??
source.plan ??
source.reason ??
fallback.summary,
fallback.summary,
);
const confidenceThreshold = normalizeRatioNumber(
source.confidenceThreshold ?? source.confidence ?? source.threshold,
fallback.confidenceThreshold,
0.45,
0.95,
);
const maxDailyOrders = normalizeIntegerValue(
source.maxDailyOrders ?? source.dailyOrderLimit ?? source.maxOrdersPerDay ?? source.orderLimit,
fallback.maxDailyOrders,
1,
200,
);
const cooldownSec = normalizeIntegerValue(
source.cooldownSec ?? source.cooldownSeconds ?? source.cooldown ?? source.minIntervalSec,
fallback.cooldownSec,
10,
600,
);
const maxOrderAmountRatio = normalizeRatioNumber(
source.maxOrderAmountRatio ??
source.maxPositionRatio ??
source.positionSizeRatio ??
source.orderAmountRatio,
fallback.maxOrderAmountRatio,
0.05,
1,
);
return {
summary,
confidenceThreshold,
maxDailyOrders,
cooldownSec,
maxOrderAmountRatio,
};
}
function resolveCompilePayloadSource(raw: unknown): Record<string, unknown> | null {
if (!raw || typeof raw !== "object") return null;
const source = raw as Record<string, unknown>;
if (
source.summary ||
source.strategySummary ||
source.confidenceThreshold ||
source.maxDailyOrders ||
source.cooldownSec ||
source.maxOrderAmountRatio
) {
return source;
}
const nestedCandidate =
source.strategy ??
source.compiledStrategy ??
source.result ??
source.output ??
source.data ??
source.payload;
if (!nestedCandidate || typeof nestedCandidate !== "object") {
return source;
}
return nestedCandidate as Record<string, unknown>;
}
function normalizeSummaryText(raw: unknown, fallback: string) {
const text = typeof raw === "string" ? raw.trim() : "";
if (!text) return fallback;
return text.slice(0, 320);
}
function normalizeRatioNumber(
raw: unknown,
fallback: number,
min: number,
max: number,
) {
let value = typeof raw === "number" ? raw : Number.parseFloat(String(raw ?? ""));
if (!Number.isFinite(value)) return fallback;
if (value > 1 && value <= 100) value /= 100;
return Math.min(max, Math.max(min, value));
}
function normalizeIntegerValue(
raw: unknown,
fallback: number,
min: number,
max: number,
) {
const value = Number.parseInt(String(raw ?? ""), 10);
if (!Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, value));
}
function summarizeCompileParseFailure(raw: unknown) {
if (raw === null || raw === undefined) return "empty";
if (typeof raw === "string") return `string:${raw.slice(0, 80)}`;
if (typeof raw !== "object") return typeof raw;
try {
const keys = Object.keys(raw as Record<string, unknown>).slice(0, 8);
return `keys:${keys.join("|") || "none"}`;
} catch {
return "object";
}
}
function finalizeCompiledSummary(params: {
summary: string;
prompt: string;
selectedTechniques: readonly string[];
}) {
const cleanedSummary = params.summary.trim();
const prompt = params.prompt.trim();
if (!prompt) {
return cleanedSummary.slice(0, 320);
}
const loweredSummary = cleanedSummary.toLowerCase();
const loweredPrompt = prompt.toLowerCase();
const suspiciousPhrases = [
"테스트 목적",
"테스트용",
"sample",
"example",
"for testing",
"test purpose",
];
const hasSuspiciousPhrase =
suspiciousPhrases.some((phrase) => loweredSummary.includes(phrase)) &&
!suspiciousPhrases.some((phrase) => loweredPrompt.includes(phrase));
const hasPromptCoverage = detectPromptCoverage(cleanedSummary, prompt);
if (hasSuspiciousPhrase) {
return buildPromptAnchoredSummary(prompt, params.selectedTechniques, cleanedSummary);
}
if (!hasPromptCoverage) {
return buildPromptAnchoredSummary(prompt, params.selectedTechniques, cleanedSummary);
}
return cleanedSummary.slice(0, 320);
}
function detectPromptCoverage(summary: string, prompt: string) {
const normalizedSummary = normalizeCoverageText(summary);
const keywords = extractPromptKeywords(prompt);
if (keywords.length === 0) return true;
return keywords.some((keyword) => normalizedSummary.includes(keyword));
}
function normalizeCoverageText(text: string) {
return text
.toLowerCase()
.replace(/[^a-z0-9가-힣]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function extractPromptKeywords(prompt: string) {
const stopwords = new Set([
"그리고",
"그냥",
"우선",
"위주",
"중심",
"하게",
"하면",
"현재",
"지금",
"please",
"with",
"from",
"that",
"this",
]);
return normalizeCoverageText(prompt)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !stopwords.has(token))
.slice(0, 12);
}
function buildPromptAnchoredSummary(
prompt: string,
selectedTechniques: readonly string[],
aiSummary?: string,
) {
const promptExcerpt = prompt.replace(/\s+/g, " ").trim().slice(0, 120);
const techniquesText =
selectedTechniques.length > 0 ? ` (${selectedTechniques.join(", ")})` : "";
const aiSummaryText = aiSummary?.replace(/\s+/g, " ").trim().slice(0, 120);
if (!aiSummaryText) {
return `프롬프트 반영 전략${techniquesText}: ${promptExcerpt}`.slice(0, 320);
}
return `프롬프트 반영 전략${techniquesText}: ${promptExcerpt} | AI요약: ${aiSummaryText}`.slice(
0,
320,
);
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
AUTOTRADE_API_ERROR_CODE,
createAutotradeErrorResponse,
getAutotradeUserId,
readJsonBody,
} from "@/app/api/autotrade/_shared";
import { buildRiskEnvelope } from "@/lib/autotrade/risk";
const validateRequestSchema = z.object({
cashBalance: z.number().nonnegative(),
allocationPercent: z.number().nonnegative(),
allocationAmount: z.number().positive(),
dailyLossPercent: z.number().nonnegative(),
dailyLossAmount: z.number().positive(),
});
export async function POST(request: Request) {
const userId = await getAutotradeUserId(request.headers);
if (!userId) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const rawBody = await readJsonBody(request);
const parsed = validateRequestSchema.safeParse(rawBody);
if (!parsed.success) {
return createAutotradeErrorResponse({
status: 400,
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
message: parsed.error.issues[0]?.message ?? "검증 입력값이 올바르지 않습니다.",
});
}
return NextResponse.json({
ok: true,
validation: buildRiskEnvelope(parsed.data),
});
}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import {
AUTOTRADE_API_ERROR_CODE,
AUTOTRADE_WORKER_TOKEN_HEADER,
createAutotradeErrorResponse,
isAutotradeWorkerAuthorized,
listAutotradeSessions,
sanitizeAutotradeError,
sweepExpiredAutotradeSessions,
} from "@/app/api/autotrade/_shared";
export async function POST(request: Request) {
if (!isAutotradeWorkerAuthorized(request.headers)) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: `${AUTOTRADE_WORKER_TOKEN_HEADER} 인증이 필요합니다.`,
});
}
try {
const sweep = sweepExpiredAutotradeSessions();
const sessions = listAutotradeSessions();
return NextResponse.json({
ok: true,
sweep,
runningSessions: sessions.filter((session) => session.runtimeState === "RUNNING").length,
stoppedSessions: sessions.filter((session) => session.runtimeState === "STOPPED").length,
checkedAt: new Date().toISOString(),
});
} catch (error) {
return createAutotradeErrorResponse({
status: 500,
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
message: sanitizeAutotradeError(error, "자동매매 워커 점검 중 오류가 발생했습니다."),
});
}
}

View File

@@ -15,6 +15,9 @@ import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
"1m",
"5m",
"10m",
"15m",
"30m",
"1h",
"1d",

View File

@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import type { DashboardMarketHubResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardMarketHub } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/**
* @file app/api/kis/domestic/market-hub/route.ts
* @description 국내주식 시장 허브(급등/인기/뉴스) 조회 API
*/
/**
* 대시보드 시장 허브 조회 API
* @returns 급등주식/인기종목/주요뉴스 목록
* @remarks UI 흐름: DashboardContainer -> useDashboardData -> /api/kis/domestic/market-hub -> MarketHubSection 렌더링
*/
export async function GET(request: Request) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
});
}
try {
const result = await getDomesticDashboardMarketHub(credentials);
const response: DashboardMarketHubResponse = {
source: "kis",
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
gainers: result.gainers,
losers: result.losers,
popularByVolume: result.popularByVolume,
popularByValue: result.popularByValue,
news: result.news,
pulse: result.pulse,
warnings: result.warnings,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(
error,
"시장 허브 조회 중 오류가 발생했습니다.",
),
});
}
}

View File

@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { executeInquireOrderableCash } from "@/lib/kis/trade";
import type { DashboardStockOrderableCashResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import {
readKisAccountParts,
readKisCredentialsFromHeaders,
} from "@/app/api/kis/domestic/_shared";
/**
* @file app/api/kis/domestic/orderable-cash/route.ts
* @description 국내주식 매수가능금액(주문가능현금) 조회 API
*/
const orderableCashBodySchema = z.object({
symbol: z
.string()
.trim()
.regex(/^\d{6}$/, "종목코드는 6자리 숫자여야 합니다."),
price: z.coerce.number().positive("기준 가격은 0보다 커야 합니다."),
orderType: z.enum(["limit", "market"]).default("market"),
});
export async function POST(request: NextRequest) {
const credentials = readKisCredentialsFromHeaders(request.headers);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv,
});
}
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
tradingEnv,
});
}
const account = readKisAccountParts(request.headers);
if (!account) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
tradingEnv,
});
}
try {
let rawBody: unknown = {};
try {
rawBody = (await request.json()) as unknown;
} catch {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "요청 본문(JSON)을 읽을 수 없습니다.",
tradingEnv,
});
}
const parsed = orderableCashBodySchema.safeParse(rawBody);
if (!parsed.success) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: parsed.error.issues[0]?.message ?? "요청값이 올바르지 않습니다.",
tradingEnv,
});
}
const result = await executeInquireOrderableCash(
{
symbol: parsed.data.symbol,
price: parsed.data.price,
orderType: parsed.data.orderType,
accountNo: account.accountNo,
accountProductCode: account.accountProductCode,
},
credentials,
);
const response: DashboardStockOrderableCashResponse = {
ok: true,
tradingEnv,
orderableCash: result.orderableCash,
noReceivableBuyAmount: result.noReceivableBuyAmount,
maxBuyAmount: result.maxBuyAmount,
maxBuyQuantity: result.maxBuyQuantity,
noReceivableBuyQuantity: result.noReceivableBuyQuantity,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "매수가능금액 조회 중 오류가 발생했습니다."),
tradingEnv,
});
}
}

View File

@@ -0,0 +1,64 @@
/**
* @file app/api/kis/indices/route.ts
* @description 국내 KOSPI/KOSDAQ 지수 조회 API
*
* @description [주요 책임]
* - 로그인 및 KIS API 설정 여부 확인
* - `getDomesticDashboardIndices` 함수를 호출하여 지수 데이터를 조회
* - 조회된 데이터를 클라이언트에 JSON 형식으로 반환
*/
import { NextResponse } from "next/server";
import { hasKisConfig } from "@/lib/kis/config";
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
export async function GET(request: Request) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
});
}
try {
const indices = await getDomesticDashboardIndices(credentials);
return NextResponse.json(
{
indices,
fetchedAt: new Date().toISOString(),
},
{
headers: {
"cache-control": "no-store",
},
},
);
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(
error,
"지수 조회 중 오류가 발생했습니다.",
),
});
}
}