전체적인 리팩토링
This commit is contained in:
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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user