/** * [파일 역할] * 구독형 CLI(Codex/Gemini)로 전략/신호 JSON을 생성하는 Provider 계층입니다. * * [주요 책임] * - vendor 선택(auto/codex/gemini) 및 실행 * - vendor별 model 옵션 적용(--model) * - 실행 결과를 파싱 가능한 형태로 반환(vendor/model/attempts) */ import { spawn } from "node:child_process"; import { existsSync, readdirSync } from "node:fs"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AutotradeCompiledStrategy, AutotradeMarketSnapshot, AutotradeTechniqueId, } from "@/features/autotrade/types/autotrade.types"; type CliVendor = "gemini" | "codex"; type CliVendorPreference = "auto" | CliVendor; const DEFAULT_TIMEOUT_MS = 60_000; export interface SubscriptionCliAttemptInfo { vendor: CliVendor; model: string | null; status: "ok" | "empty_output" | "process_error" | "timeout"; command?: string; detail?: string; } export interface SubscriptionCliParsedResult { parsed: unknown | null; vendor: CliVendor | null; model: string | null; attempts: SubscriptionCliAttemptInfo[]; } const ATTEMPT_STATUS_LABEL: Record = { ok: "ok", empty_output: "empty", process_error: "error", timeout: "timeout", }; // [목적] 구독형 CLI(codex/gemini)로 전략 JSON을 생성합니다. export async function compileStrategyWithSubscriptionCli(params: { prompt: string; selectedTechniques: AutotradeTechniqueId[]; confidenceThreshold: number; }) { const result = await compileStrategyWithSubscriptionCliDetailed(params); return result.parsed; } // [목적] 구독형 CLI(codex/gemini) 전략 생성 + vendor/시도 정보 추적 export async function compileStrategyWithSubscriptionCliDetailed(params: { prompt: string; selectedTechniques: AutotradeTechniqueId[]; confidenceThreshold: number; preferredVendor?: CliVendorPreference; preferredModel?: string; }): Promise { const prompt = [ "너는 자동매매 전략 컴파일러다.", "반드시 JSON 객체만 출력한다. 코드블록/설명문 금지.", "summary는 반드시 한국어 1문장으로 작성하고, 사용자 프롬프트의 핵심 단어를 1개 이상 포함한다.", "summary에 'provided prompt', '테스트 목적', 'sample/example' 같은 추상 문구만 쓰지 않는다.", '필수 키: summary, confidenceThreshold, maxDailyOrders, cooldownSec, maxOrderAmountRatio', "제약: confidenceThreshold(0.45~0.95), maxDailyOrders(1~200), cooldownSec(10~600), maxOrderAmountRatio(0.05~1)", `입력: ${JSON.stringify(params)}`, ].join("\n"); const execution = await executeSubscriptionCliPrompt(prompt, { preferredVendor: params.preferredVendor, preferredModel: params.preferredModel, }); if (!execution.output) { return { parsed: null, vendor: execution.vendor, model: execution.model, attempts: execution.attempts, }; } return { parsed: extractJsonObject(execution.output), vendor: execution.vendor, model: execution.model, attempts: execution.attempts, }; } // [목적] 구독형 CLI(codex/gemini)로 신호 JSON을 생성합니다. export async function generateSignalWithSubscriptionCli(params: { prompt: string; strategy: AutotradeCompiledStrategy; snapshot: AutotradeMarketSnapshot; }) { const result = await generateSignalWithSubscriptionCliDetailed(params); return result.parsed; } // [목적] 구독형 CLI(codex/gemini) 신호 생성 + vendor/시도 정보 추적 export async function generateSignalWithSubscriptionCliDetailed(params: { prompt: string; strategy: AutotradeCompiledStrategy; snapshot: AutotradeMarketSnapshot; preferredVendor?: CliVendorPreference; preferredModel?: string; }): Promise { const prompt = [ "너는 자동매매 신호 생성기다.", "반드시 JSON 객체만 출력한다. 코드블록/설명문 금지.", "필수 키: signal, confidence, reason, ttlSec, riskFlags", "signal은 buy/sell/hold, confidence는 0~1", "reason은 반드시 한국어 한 문장으로 작성한다.", "operatorPrompt가 비어있지 않으면 strategy.summary보다 우선 참고하되, 무리한 진입은 금지한다.", "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를 우선한다.", "확신이 낮거나 위험하면 hold를 반환한다.", `입력: ${JSON.stringify({ operatorPrompt: params.prompt, strategy: params.strategy, snapshot: params.snapshot, })}`, ].join("\n"); const execution = await executeSubscriptionCliPrompt(prompt, { preferredVendor: params.preferredVendor, preferredModel: params.preferredModel, }); if (!execution.output) { return { parsed: null, vendor: execution.vendor, model: execution.model, attempts: execution.attempts, }; } return { parsed: extractJsonObject(execution.output), vendor: execution.vendor, model: execution.model, attempts: execution.attempts, }; } // [목적] API 응답/로그에서 codex/gemini 실제 실행 흐름을 사람이 읽기 쉬운 문자열로 제공합니다. export function summarizeSubscriptionCliExecution(execution: { vendor: CliVendor | null; model: string | null; attempts: SubscriptionCliAttemptInfo[]; }) { const selectedVendor = execution.vendor ? execution.vendor : "none"; const selectedModel = execution.model ?? "default"; const attemptsText = execution.attempts.length > 0 ? execution.attempts .map((attempt) => { const model = attempt.model ?? "default"; const detail = attempt.detail ? `(${sanitizeAttemptDetail(attempt.detail)})` : ""; return `${attempt.vendor}:${model}:${ATTEMPT_STATUS_LABEL[attempt.status]}${detail}`; }) .join(", ") : "none"; return `selected=${selectedVendor}:${selectedModel}; attempts=${attemptsText}`; } async function executeSubscriptionCliPrompt( prompt: string, options?: { preferredVendor?: CliVendorPreference; preferredModel?: string; }, ) { // auto 모드: codex 우선 시도 후 gemini로 폴백 const preferredVendor = normalizeCliVendorPreference(options?.preferredVendor); const mode = preferredVendor ?? normalizeCliVendorPreference(process.env.AUTOTRADE_SUBSCRIPTION_CLI) ?? "auto"; const vendors: CliVendor[] = mode === "gemini" ? ["gemini"] : mode === "codex" ? ["codex"] : ["codex", "gemini"]; const attempts: SubscriptionCliAttemptInfo[] = []; const preferredModel = normalizeCliModel(options?.preferredModel); const debugEnabled = isSubscriptionCliDebugEnabled(); if (debugEnabled) { console.info( `[autotrade-cli] mode=${mode} preferredVendor=${preferredVendor ?? "none"} preferredModel=${preferredModel ?? "default"} prompt="${toPromptPreview(prompt)}"`, ); } for (const vendor of vendors) { // [Step 1] vendor별 모델 선택: vendor 전용 변수 -> 공통 변수 순서로 해석 const model = preferredModel ?? resolveSubscriptionCliModel(vendor); // [Step 2] CLI 실행 후 시도 결과를 누적합니다. const result = await runCliVendor({ vendor, prompt, model }); attempts.push(result.attempt); if (debugEnabled) { console.info( `[autotrade-cli] vendor=${vendor} model=${model ?? "default"} status=${result.attempt.status} command=${result.attempt.command ?? vendor} detail=${result.attempt.detail ?? "-"}`, ); } if (result.output) { return { output: result.output, vendor, model, attempts, }; } } return { output: null, vendor: null, model: null, attempts, }; } async function runCliVendor(params: { vendor: CliVendor; prompt: string; model: string | null; }) { const { vendor, prompt, model } = params; const commands = resolveCliCommandCandidates(vendor); // gemini는 headless prompt 옵션으로 직접 결과를 stdout으로 받습니다. if (vendor === "gemini") { const tempDir = await mkdtemp(join(tmpdir(), "autotrade-gemini-")); try { // [안전 설정] // - approvalMode=plan으로 전역 설정된 환경에서도 비대화형 호출이 깨지지 않도록 default를 명시합니다. // - 실행 CWD는 임시 디렉터리(tempDir)로 고정해, 도구 호출이 발생해도 프로젝트 소스 수정 위험을 줄입니다. const args = ["-p", prompt, "--output-format", "text", "--approval-mode", "default"]; if (model) { // 공식 문서 기준: Gemini CLI는 --model(-m)로 모델 지정 지원 args.push("--model", model); } const commandResult = await runProcessWithCommandCandidates(commands, args, { cwd: tempDir, // Windows에서 gemini는 npm shim(.cmd) 실행을 고려해 shell 모드를 사용합니다. shell: process.platform === "win32", }); const result = commandResult.result; const output = result.stdout.trim(); if (output) { // 출력이 있으면 우선 파싱 단계로 넘깁니다. return { output, attempt: { vendor, model, status: "ok", command: commandResult.command, } satisfies SubscriptionCliAttemptInfo, }; } if (result.exitCode !== 0) { const status = result.stderr.includes("[timeout]") ? "timeout" : "process_error"; return { output: null, attempt: { vendor, model, status, command: commandResult.command, detail: resolveAttemptDetail(result), } satisfies SubscriptionCliAttemptInfo, }; } return { output: null, attempt: { vendor, model, status: "empty_output", command: commandResult.command, detail: resolveAttemptDetail(result), } satisfies SubscriptionCliAttemptInfo, }; } finally { await rm(tempDir, { recursive: true, force: true }).catch(() => {}); } } const tempDir = await mkdtemp(join(tmpdir(), "autotrade-codex-")); const outputPath = join(tempDir, "last-message.txt"); try { // codex는 마지막 메시지를 파일로 받아 안정적으로 파싱합니다. const args = ["exec", "--skip-git-repo-check", "--sandbox", "read-only"]; if (model) { // 공식 문서 기준: Codex CLI는 exec에서 --model(-m) 옵션으로 모델 지정 지원 args.push("--model", model); } const codexEffortOverride = resolveCodexReasoningEffortOverride(model); if (codexEffortOverride) { // gpt-5-codex는 xhigh를 지원하지 않아 high로 강제합니다. args.push("-c", `model_reasoning_effort=\"${codexEffortOverride}\"`); } args.push("-o", outputPath, prompt); const commandResult = await runProcessWithCommandCandidates(commands, args, { cwd: tempDir, // codex는 prompt 문자열을 인자로 안정 전달해야 하므로 shell 모드를 사용하지 않습니다. shell: false, }); const result = commandResult.result; const output = await readFile(outputPath, "utf-8") .then((value) => value.trim()) .catch(() => result.stdout.trim()); if (!output) { if (result.exitCode !== 0) { const status = result.stderr.includes("[timeout]") ? "timeout" : "process_error"; return { output: null, attempt: { vendor, model, status, command: commandResult.command, detail: resolveAttemptDetail(result), } satisfies SubscriptionCliAttemptInfo, }; } return { output: null, attempt: { vendor, model, status: "empty_output", command: commandResult.command, detail: resolveAttemptDetail(result), } satisfies SubscriptionCliAttemptInfo, }; } return { output, attempt: { vendor, model, status: "ok", command: commandResult.command, } satisfies SubscriptionCliAttemptInfo, }; } catch (error) { const detail = error instanceof Error ? error.message : "unexpected_error"; return { output: null, attempt: { vendor, model, status: "process_error", detail, } satisfies SubscriptionCliAttemptInfo, }; } finally { await rm(tempDir, { recursive: true, force: true }).catch(() => {}); } } function runProcess( command: string, args: string[], options?: { cwd?: string; shell?: boolean; }, ) { return new Promise<{ stdout: string; stderr: string; exitCode: number; spawnErrorCode: string | null; spawnErrorMessage: string | null; }>((resolve) => { const child = spawn(command, args, { cwd: options?.cwd, shell: options?.shell ?? false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; let killedByTimeout = false; let spawnErrorCode: string | null = null; let spawnErrorMessage: string | null = null; let completed = false; const timeoutMs = resolveCliTimeoutMs(); const resolveOnce = (payload: { stdout: string; stderr: string; exitCode: number; spawnErrorCode: string | null; spawnErrorMessage: string | null; }) => { if (completed) return; completed = true; resolve(payload); }; const timer = setTimeout(() => { killedByTimeout = true; child.kill("SIGKILL"); }, timeoutMs); child.stdout.on("data", (chunk) => { stdout += String(chunk); }); child.stderr.on("data", (chunk) => { stderr += String(chunk); }); child.on("error", (error) => { clearTimeout(timer); spawnErrorCode = typeof (error as NodeJS.ErrnoException).code === "string" ? (error as NodeJS.ErrnoException).code! : "SPAWN_ERROR"; spawnErrorMessage = error.message; resolveOnce({ stdout, stderr, exitCode: 1, spawnErrorCode, spawnErrorMessage, }); }); child.on("close", (code) => { clearTimeout(timer); resolveOnce({ stdout, stderr: killedByTimeout ? `${stderr}\n[timeout]` : stderr, exitCode: typeof code === "number" ? code : 1, spawnErrorCode, spawnErrorMessage, }); }); }); } function resolveCliTimeoutMs() { const raw = Number.parseInt(process.env.AUTOTRADE_SUBSCRIPTION_CLI_TIMEOUT_MS ?? "", 10); if (!Number.isFinite(raw)) return DEFAULT_TIMEOUT_MS; return Math.max(5_000, Math.min(120_000, raw)); } function resolveSubscriptionCliModel(vendor: CliVendor) { // [Step 1] vendor별 전용 모델 환경변수를 우선 적용합니다. const vendorSpecific = vendor === "codex" ? process.env.AUTOTRADE_CODEX_MODEL : process.env.AUTOTRADE_GEMINI_MODEL; const normalizedVendorSpecific = normalizeCliModel(vendorSpecific); if (normalizedVendorSpecific) { return normalizedVendorSpecific; } // [Step 2] 공통 모델 환경변수(AUTOTRADE_SUBSCRIPTION_CLI_MODEL)를 fallback으로 사용합니다. return normalizeCliModel(process.env.AUTOTRADE_SUBSCRIPTION_CLI_MODEL); } function resolveCodexReasoningEffortOverride(model: string | null) { const normalized = model?.trim().toLowerCase(); if (!normalized) return null; if (normalized === "gpt-5-codex") return "high"; return null; } function normalizeCliVendorPreference(raw: string | undefined): CliVendorPreference | null { const normalized = raw?.trim().toLowerCase(); if (normalized === "codex") return "codex"; if (normalized === "gemini") return "gemini"; if (normalized === "auto") return "auto"; return null; } function normalizeCliModel(raw: string | undefined) { const normalized = raw?.trim(); return normalized ? normalized : null; } function sanitizeAttemptDetail(detail: string) { return detail.replace(/\s+/g, " ").trim().slice(0, 180); } function resolveAttemptDetail(result: { stderr: string; spawnErrorCode: string | null; spawnErrorMessage: string | null; }) { if (result.spawnErrorCode) { const message = result.spawnErrorMessage ? `spawn:${result.spawnErrorCode} ${result.spawnErrorMessage}` : `spawn:${result.spawnErrorCode}`; return sanitizeAttemptDetail(message); } const stderr = result.stderr.trim(); if (!stderr) return undefined; return sanitizeAttemptDetail(stderr); } function resolveCliCommandCandidates(vendor: CliVendor) { const envCommand = vendor === "codex" ? normalizeCliCommand(process.env.AUTOTRADE_CODEX_COMMAND) : normalizeCliCommand(process.env.AUTOTRADE_GEMINI_COMMAND); const windowsNpmCommand = vendor === "gemini" ? resolveWindowsNpmCommand(vendor) : null; const windowsCodexExecutable = vendor === "codex" ? resolveWindowsCodexExecutable() : null; const baseCommand = vendor; const candidates = [envCommand, baseCommand, windowsCodexExecutable, windowsNpmCommand].filter( (value): value is string => Boolean(value), ); return Array.from(new Set(candidates)); } async function runProcessWithCommandCandidates( commands: string[], args: string[], options?: { cwd?: string; shell?: boolean; }, ) { let lastResult: Awaited> | null = null; let lastCommand: string | null = null; for (const command of commands) { const result = await runProcess(command, args, options); lastResult = result; lastCommand = command; if (result.spawnErrorCode !== "ENOENT") { break; } } if (!lastResult || !lastCommand) { throw new Error("CLI command resolution failed"); } return { command: lastCommand, result: lastResult }; } function normalizeCliCommand(raw: string | undefined) { const normalized = raw?.trim(); return normalized ? normalized : null; } function resolveWindowsNpmCommand(vendor: CliVendor) { if (process.platform !== "win32") return null; const appData = process.env.APPDATA; if (!appData) return null; const fileName = vendor === "codex" ? "codex.cmd" : "gemini.cmd"; const candidate = join(appData, "npm", fileName); return existsSync(candidate) ? candidate : null; } function resolveWindowsCodexExecutable() { if (process.platform !== "win32") return null; const userProfile = process.env.USERPROFILE; if (!userProfile) return null; const extensionRoot = join(userProfile, ".vscode", "extensions"); if (!existsSync(extensionRoot)) return null; const candidates = readdirSync(extensionRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name.startsWith("openai.chatgpt-")) .map((entry) => join(extensionRoot, entry.name, "bin", "windows-x86_64", "codex.exe")) .filter((candidate) => existsSync(candidate)); return candidates[0] ?? null; } function isSubscriptionCliDebugEnabled() { const value = process.env.AUTOTRADE_SUBSCRIPTION_CLI_DEBUG?.trim().toLowerCase(); return value === "1" || value === "true" || value === "yes" || value === "on"; } function toPromptPreview(prompt: string) { return prompt.replace(/\s+/g, " ").trim().slice(0, 180); } function extractJsonObject(raw: string) { // CLI 출력이 코드블록/설명문을 섞어도 첫 JSON 객체를 최대한 추출합니다. const trimmed = raw.trim(); if (!trimmed) return null; const fenced = trimmed.match(/```json\s*([\s\S]*?)```/i)?.[1]; if (fenced) { try { return JSON.parse(fenced) as unknown; } catch { // no-op } } try { return JSON.parse(trimmed) as unknown; } catch { // no-op } const firstBrace = trimmed.indexOf("{"); if (firstBrace < 0) return null; let depth = 0; for (let index = firstBrace; index < trimmed.length; index += 1) { const char = trimmed[index]; if (char === "{") depth += 1; if (char === "}") depth -= 1; if (depth !== 0) continue; const candidate = trimmed.slice(firstBrace, index + 1); try { return JSON.parse(candidate) as unknown; } catch { return null; } } return null; }