/** * [파일 역할] * OpenAI Chat Completions를 이용해 자동매매 전략/신호 JSON을 생성하는 유틸입니다. * * [주요 책임] * - OpenAI 연결 가능 여부 확인 * - 전략 compile 응답 스키마 검증 * - 신호 generate 응답 스키마 검증 */ import { z } from "zod"; import type { AutotradeCompiledStrategy, AutotradeMarketSnapshot, AutotradeSignalCandidate, AutotradeTechniqueId, } from "@/features/autotrade/types/autotrade.types"; const OPENAI_CHAT_COMPLETIONS_URL = "https://api.openai.com/v1/chat/completions"; 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), }); 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 function isOpenAiConfigured() { return Boolean(process.env.OPENAI_API_KEY?.trim()); } // [목적] 자연어 프롬프트 + 기법 선택을 실행 가능한 전략 JSON으로 변환합니다. export async function compileStrategyWithOpenAi(params: { prompt: string; selectedTechniques: AutotradeTechniqueId[]; confidenceThreshold: number; }) { // [Step 1] 전략 컴파일용 JSON 스키마 + 프롬프트를 구성합니다. const response = await callOpenAiJson({ schemaName: "autotrade_strategy_compile", schema: { type: "object", additionalProperties: false, properties: { summary: { type: "string" }, confidenceThreshold: { type: "number" }, maxDailyOrders: { type: "integer" }, cooldownSec: { type: "integer" }, maxOrderAmountRatio: { type: "number" }, }, required: [ "summary", "confidenceThreshold", "maxDailyOrders", "cooldownSec", "maxOrderAmountRatio", ], }, messages: [ { role: "system", content: "너는 자동매매 전략 컴파일러다. 설명문 없이 JSON만 반환하고 리스크를 보수적으로 설정한다.", }, { role: "user", content: JSON.stringify( { prompt: params.prompt, selectedTechniques: params.selectedTechniques, techniqueGuide: { orb: "시가 범위 돌파 추세", vwap_reversion: "VWAP 평균회귀", volume_breakout: "거래량 동반 돌파", ma_crossover: "단기/중기 이평 교차", gap_breakout: "갭 이후 돌파/이탈", intraday_box_reversion: "당일 상승 후 박스권 상하단 단타", intraday_breakout_scalp: "1분봉 상승 추세에서 눌림 후 재돌파(거래량 재유입) 단타", }, baselineConfidenceThreshold: params.confidenceThreshold, rules: [ "confidenceThreshold는 0.45~0.95", "maxDailyOrders는 1~200", "cooldownSec는 10~600", "maxOrderAmountRatio는 0.05~1", ], }, null, 2, ), }, ], }); // [Step 2] 응답이 없거나 스키마 불일치면 null로 반환해 상위 라우트가 fallback을 적용하게 합니다. if (!response) return null; const parsed = compileResultSchema.safeParse(response); if (!parsed.success) { return null; } return parsed.data; } // [목적] 시세 스냅샷을 기반으로 buy/sell/hold 후보 신호 JSON을 생성합니다. export async function generateSignalWithOpenAi(params: { prompt: string; strategy: AutotradeCompiledStrategy; snapshot: AutotradeMarketSnapshot; }) { // [Step 1] 전략 + 시세 스냅샷을 신호 생성 JSON 스키마에 맞춰 전달합니다. const response = await callOpenAiJson({ schemaName: "autotrade_signal_candidate", schema: { type: "object", additionalProperties: false, properties: { signal: { type: "string", enum: ["buy", "sell", "hold"], }, confidence: { type: "number" }, reason: { type: "string" }, ttlSec: { type: "integer" }, riskFlags: { type: "array", items: { type: "string" }, }, proposedOrder: { type: "object", additionalProperties: false, properties: { symbol: { type: "string" }, side: { type: "string", enum: ["buy", "sell"], }, orderType: { type: "string", enum: ["limit", "market"], }, price: { type: "number" }, quantity: { type: "integer" }, }, required: ["symbol", "side", "orderType"], }, }, required: ["signal", "confidence", "reason", "ttlSec", "riskFlags"], }, messages: [ { role: "system", content: "너는 주문 실행기가 아니라 신호 생성기다. JSON 외 텍스트를 출력하지 말고, 근거 없는 공격적 신호를 피한다. 스냅샷의 체결/호가/모멘텀/박스권 지표와 최근 1분봉 구조를 함께 보고 판단한다.", }, { role: "user", content: JSON.stringify( { operatorPrompt: params.prompt, strategy: { summary: params.strategy.summary, selectedTechniques: params.strategy.selectedTechniques, confidenceThreshold: params.strategy.confidenceThreshold, maxDailyOrders: params.strategy.maxDailyOrders, cooldownSec: params.strategy.cooldownSec, }, snapshot: params.snapshot, constraints: [ "reason은 한국어 한 줄로 작성", "확신이 낮으면 hold를 우선 선택", "operatorPrompt에 세부 규칙이 있으면 strategy.summary보다 우선 참고하되, 리스크 보수성은 유지", "spreadRate(호가 스프레드), dayRangePosition(당일 범위 위치), volumeRatio(체결량 비율), intradayMomentum(단기 모멘텀), recentReturns(최근 수익률 흐름)을 함께 고려", "tradeVolume은 단일 틱 체결 수량일 수 있으므로 accumulatedVolume, recentTradeCount, recentTradeVolumeSum, liquidityDepth(호가 총잔량), orderBookImbalance를 함께 보고 유동성을 판단", "tickTime/requestAtKst/marketDataLatencySec을 보고 데이터 시차가 큰 경우 과감한 진입을 피한다", "recentTradeVolumes/recentNetBuyTrail/recentTickAgesSec에서 체결 흐름의 연속성(매수 우위 지속/이탈)을 확인한다", "topLevelOrderBookImbalance와 buySellExecutionRatio를 함께 보고 상단 호가에서의 단기 수급 왜곡을 확인한다", "recentMinuteCandles와 minutePatternContext가 있으면 직전 3~10봉 추세와 최근 2~8봉 압축 구간을 우선 판별한다", "minutePatternContext.impulseDirection/impulseChangeRate/consolidationRangePercent/consolidationVolumeRatio/breakoutUpper/breakoutLower를 함께 보고 박스 압축 후 돌파/이탈 가능성을 판단한다", "budgetContext가 있으면 estimatedBuyableQuantity(예산 기준 최대 매수 가능 주수)를 초과하는 공격적 진입을 피한다", "portfolioContext가 있으면 sellableQuantity(실제 매도 가능 수량)가 0인 상태에서는 sell 신호를 매우 보수적으로 본다", "executionCostProfile이 있으면 매도 시 수수료+세금을 고려한 순손익 관점으로 판단한다", "압축 구간이 애매하거나 박스 내부 중간값이면 추격 진입 대신 hold를 우선한다", "intraday_breakout_scalp 기법이 포함되면 상승 추세, 눌림 깊이, 재돌파 거래량 확인이 동시에 만족될 때만 buy를 고려", ], }, null, 2, ), }, ], }); // [Step 2] 응답이 없거나 스키마 불일치면 null 반환 -> 상위 라우트가 fallback 신호로 대체합니다. if (!response) return null; const parsed = signalResultSchema.safeParse(response); if (!parsed.success) { return null; } return { ...parsed.data, source: "openai", } satisfies AutotradeSignalCandidate; } async function callOpenAiJson(params: { schemaName: string; schema: Record; messages: Array<{ role: "system" | "user"; content: string }>; }) { // [데이터 흐름] route.ts -> callOpenAiJson -> OpenAI chat/completions -> JSON parse -> route.ts 반환 const apiKey = process.env.OPENAI_API_KEY?.trim(); if (!apiKey) return null; const model = process.env.AUTOTRADE_AI_MODEL?.trim() || "gpt-4o-mini"; let response: Response; try { // [Step 1] OpenAI Chat Completions(JSON Schema 강제) 호출 response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model, temperature: 0.2, response_format: { type: "json_schema", json_schema: { name: params.schemaName, strict: true, schema: params.schema, }, }, messages: params.messages, }), cache: "no-store", }); } catch { return null; } let payload: unknown; try { // [Step 2] HTTP 응답 JSON 파싱 payload = await response.json(); } catch { return null; } if (!response.ok || !payload || typeof payload !== "object") { return null; } const content = (payload as { choices?: Array<{ message?: { content?: string } }> }).choices?.[0]?.message ?.content ?? ""; if (!content.trim()) { return null; } try { // [Step 3] 모델이 반환한 JSON 문자열을 실제 객체로 변환 return JSON.parse(content) as unknown; } catch { return null; } }