전체적인 리팩토링

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

View File

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

View File

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