412 lines
13 KiB
TypeScript
412 lines
13 KiB
TypeScript
/**
|
|
* [파일 역할]
|
|
* 전략 프롬프트를 실행 가능한 자동매매 전략(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,
|
|
);
|
|
}
|