/** * [파일 역할] * 전략 프롬프트를 실행 가능한 자동매매 전략(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) { 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 | null { if (!raw || typeof raw !== "object") return null; const source = raw as Record; 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; } 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).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, ); }