Files
auto-trade/lib/autotrade/cli-provider.ts

650 lines
21 KiB
TypeScript
Raw Permalink Normal View History

2026-03-12 09:26:27 +09:00
/**
* [ ]
* 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<SubscriptionCliAttemptInfo["status"], string> = {
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<SubscriptionCliParsedResult> {
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<SubscriptionCliParsedResult> {
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<ReturnType<typeof runProcess>> | 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;
}