전체적인 리팩토링
This commit is contained in:
227
app/api/autotrade/_shared.ts
Normal file
227
app/api/autotrade/_shared.ts
Normal 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;
|
||||
}
|
||||
25
app/api/autotrade/sessions/active/route.ts
Normal file
25
app/api/autotrade/sessions/active/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
73
app/api/autotrade/sessions/heartbeat/route.ts
Normal file
73
app/api/autotrade/sessions/heartbeat/route.ts
Normal 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 처리 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
77
app/api/autotrade/sessions/start/route.ts
Normal file
77
app/api/autotrade/sessions/start/route.ts
Normal 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, "자동매매 세션 시작 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
78
app/api/autotrade/sessions/stop/route.ts
Normal file
78
app/api/autotrade/sessions/stop/route.ts
Normal 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, "세션 종료 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
440
app/api/autotrade/signals/generate/route.ts
Normal file
440
app/api/autotrade/signals/generate/route.ts
Normal 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;
|
||||
}
|
||||
411
app/api/autotrade/strategies/compile/route.ts
Normal file
411
app/api/autotrade/strategies/compile/route.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
43
app/api/autotrade/strategies/validate/route.ts
Normal file
43
app/api/autotrade/strategies/validate/route.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
39
app/api/autotrade/worker/tick/route.ts
Normal file
39
app/api/autotrade/worker/tick/route.ts
Normal 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, "자동매매 워커 점검 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
|
||||
"1m",
|
||||
"5m",
|
||||
"10m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"1d",
|
||||
|
||||
72
app/api/kis/domestic/market-hub/route.ts
Normal file
72
app/api/kis/domestic/market-hub/route.ts
Normal 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,
|
||||
"시장 허브 조회 중 오류가 발생했습니다.",
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
124
app/api/kis/domestic/orderable-cash/route.ts
Normal file
124
app/api/kis/domestic/orderable-cash/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
64
app/api/kis/indices/route.ts
Normal file
64
app/api/kis/indices/route.ts
Normal 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,
|
||||
"지수 조회 중 오류가 발생했습니다.",
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user