/** * [파일 역할] * 컴파일된 전략 + 시세 스냅샷으로 매수/매도/대기 신호를 생성하는 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 | null { if (!raw || typeof raw !== "object") return null; const source = raw as Record; 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; } 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; 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; }