전체적인 리팩토링
This commit is contained in:
649
lib/autotrade/cli-provider.ts
Normal file
649
lib/autotrade/cli-provider.ts
Normal file
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* [파일 역할]
|
||||
* 구독형 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;
|
||||
}
|
||||
56
lib/autotrade/executable-order-quantity.ts
Normal file
56
lib/autotrade/executable-order-quantity.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export type ExecutableOrderSide = "buy" | "sell";
|
||||
|
||||
export interface ClampExecutableOrderQuantityParams {
|
||||
side: ExecutableOrderSide;
|
||||
requestedQuantity: number;
|
||||
maxBuyQuantity?: number;
|
||||
holdingQuantity?: number;
|
||||
sellableQuantity?: number;
|
||||
}
|
||||
|
||||
export interface ClampExecutableOrderQuantityResult {
|
||||
ok: boolean;
|
||||
quantity: number;
|
||||
adjusted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* [목적]
|
||||
* 주문 전 계좌 제약(매수가능/보유/매도가능)에 맞춰 최종 실행 수량을 순수 계산합니다.
|
||||
*
|
||||
* [입력 -> 처리 -> 결과]
|
||||
* 요청 수량/사이드/제약값 -> 가능 최대 수량으로 clamp -> 실행 가능 여부와 수량 반환
|
||||
*/
|
||||
export function clampExecutableOrderQuantity(
|
||||
params: ClampExecutableOrderQuantityParams,
|
||||
): ClampExecutableOrderQuantityResult {
|
||||
const requestedQuantity = Math.max(0, Math.floor(params.requestedQuantity));
|
||||
if (requestedQuantity <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
quantity: 0,
|
||||
adjusted: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.side === "buy") {
|
||||
const maxBuyQuantity = Math.max(0, Math.floor(params.maxBuyQuantity ?? 0));
|
||||
const quantity = Math.min(requestedQuantity, maxBuyQuantity);
|
||||
return {
|
||||
ok: quantity > 0,
|
||||
quantity,
|
||||
adjusted: quantity !== requestedQuantity,
|
||||
};
|
||||
}
|
||||
|
||||
const holdingQuantity = Math.max(0, Math.floor(params.holdingQuantity ?? 0));
|
||||
const sellableQuantity = Math.max(0, Math.floor(params.sellableQuantity ?? 0));
|
||||
const maxSellQuantity = Math.min(holdingQuantity, sellableQuantity);
|
||||
const quantity = Math.min(requestedQuantity, maxSellQuantity);
|
||||
return {
|
||||
ok: quantity > 0,
|
||||
quantity,
|
||||
adjusted: quantity !== requestedQuantity,
|
||||
};
|
||||
}
|
||||
|
||||
94
lib/autotrade/execution-cost.ts
Normal file
94
lib/autotrade/execution-cost.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
export interface AutotradeExecutionCostProfile {
|
||||
buyFeeRate: number;
|
||||
sellFeeRate: number;
|
||||
sellTaxRate: number;
|
||||
}
|
||||
|
||||
export interface AutotradeEstimatedOrderCost {
|
||||
side: "buy" | "sell";
|
||||
price: number;
|
||||
quantity: number;
|
||||
grossAmount: number;
|
||||
feeAmount: number;
|
||||
taxAmount: number;
|
||||
netAmount: number;
|
||||
}
|
||||
|
||||
export function resolveExecutionCostProfile(): AutotradeExecutionCostProfile {
|
||||
return {
|
||||
buyFeeRate: readRateFromEnv("AUTOTRADE_BUY_FEE_RATE", 0.00015),
|
||||
sellFeeRate: readRateFromEnv("AUTOTRADE_SELL_FEE_RATE", 0.00015),
|
||||
sellTaxRate: readRateFromEnv("AUTOTRADE_SELL_TAX_RATE", 0.0018),
|
||||
};
|
||||
}
|
||||
|
||||
export function estimateBuyUnitCost(
|
||||
price: number,
|
||||
profile: AutotradeExecutionCostProfile,
|
||||
) {
|
||||
const safePrice = Math.max(0, price);
|
||||
if (safePrice <= 0) return 0;
|
||||
return safePrice * (1 + Math.max(0, profile.buyFeeRate));
|
||||
}
|
||||
|
||||
export function estimateSellNetUnit(
|
||||
price: number,
|
||||
profile: AutotradeExecutionCostProfile,
|
||||
) {
|
||||
const safePrice = Math.max(0, price);
|
||||
if (safePrice <= 0) return 0;
|
||||
const totalRate = Math.max(0, profile.sellFeeRate) + Math.max(0, profile.sellTaxRate);
|
||||
return safePrice * (1 - totalRate);
|
||||
}
|
||||
|
||||
export function estimateOrderCost(params: {
|
||||
side: "buy" | "sell";
|
||||
price: number;
|
||||
quantity: number;
|
||||
profile: AutotradeExecutionCostProfile;
|
||||
}): AutotradeEstimatedOrderCost {
|
||||
const safePrice = Math.max(0, params.price);
|
||||
const safeQuantity = Math.max(0, Math.floor(params.quantity));
|
||||
const grossAmount = safePrice * safeQuantity;
|
||||
|
||||
if (params.side === "buy") {
|
||||
const feeAmount = grossAmount * Math.max(0, params.profile.buyFeeRate);
|
||||
return {
|
||||
side: params.side,
|
||||
price: safePrice,
|
||||
quantity: safeQuantity,
|
||||
grossAmount,
|
||||
feeAmount,
|
||||
taxAmount: 0,
|
||||
netAmount: grossAmount + feeAmount,
|
||||
};
|
||||
}
|
||||
|
||||
const feeAmount = grossAmount * Math.max(0, params.profile.sellFeeRate);
|
||||
const taxAmount = grossAmount * Math.max(0, params.profile.sellTaxRate);
|
||||
return {
|
||||
side: params.side,
|
||||
price: safePrice,
|
||||
quantity: safeQuantity,
|
||||
grossAmount,
|
||||
feeAmount,
|
||||
taxAmount,
|
||||
netAmount: grossAmount - feeAmount - taxAmount,
|
||||
};
|
||||
}
|
||||
|
||||
function readRateFromEnv(envName: string, fallback: number) {
|
||||
const raw = Number.parseFloat(process.env[envName] ?? "");
|
||||
if (!Number.isFinite(raw)) return fallback;
|
||||
|
||||
if (raw >= 1 && raw <= 100) {
|
||||
return clamp(raw / 100, 0, 0.5);
|
||||
}
|
||||
|
||||
return clamp(raw, 0, 0.5);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
293
lib/autotrade/openai.ts
Normal file
293
lib/autotrade/openai.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* [파일 역할]
|
||||
* 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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
289
lib/autotrade/risk.ts
Normal file
289
lib/autotrade/risk.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import type {
|
||||
AutotradeCompiledStrategy,
|
||||
AutotradeSignalCandidate,
|
||||
AutotradeValidationResult,
|
||||
} from "@/features/autotrade/types/autotrade.types";
|
||||
|
||||
interface RiskEnvelopeInput {
|
||||
cashBalance: number;
|
||||
allocationPercent: number;
|
||||
allocationAmount: number;
|
||||
dailyLossPercent: number;
|
||||
dailyLossAmount: number;
|
||||
}
|
||||
|
||||
interface ValidationCashBalanceInput {
|
||||
cashBalance: number;
|
||||
orderableCash?: number | null;
|
||||
}
|
||||
|
||||
type ValidationCashBalanceSource = "cash_balance" | "orderable_cash" | "min_of_both" | "none";
|
||||
|
||||
export interface ValidationCashBalanceResult {
|
||||
cashBalance: number;
|
||||
source: ValidationCashBalanceSource;
|
||||
originalCashBalance: number;
|
||||
originalOrderableCash: number;
|
||||
}
|
||||
|
||||
interface SignalGateInput {
|
||||
signal: AutotradeSignalCandidate;
|
||||
strategy: AutotradeCompiledStrategy;
|
||||
validation: AutotradeValidationResult;
|
||||
dailyOrderCount: number;
|
||||
lastOrderAtMs?: number;
|
||||
nowMs: number;
|
||||
}
|
||||
|
||||
export function clampNumber(value: number, min: number, max: number) {
|
||||
if (Number.isNaN(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function calculateEffectiveAllocationAmount(
|
||||
cashBalance: number,
|
||||
allocationPercent: number,
|
||||
allocationAmount: number,
|
||||
) {
|
||||
const safeCashBalance = Math.floor(Math.max(0, cashBalance));
|
||||
const safeAllocationAmount = Math.floor(Math.max(0, allocationAmount));
|
||||
const safeAllocationPercent = clampNumber(allocationPercent, 0, 100);
|
||||
|
||||
if (safeCashBalance <= 0 || safeAllocationPercent <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 설정창 입력(비율/금액)을 동시에 상한으로 사용합니다.
|
||||
const allocationByPercent = Math.floor((safeCashBalance * safeAllocationPercent) / 100);
|
||||
return Math.min(safeCashBalance, safeAllocationAmount, allocationByPercent);
|
||||
}
|
||||
|
||||
export function resolveValidationCashBalance(
|
||||
input: ValidationCashBalanceInput,
|
||||
): ValidationCashBalanceResult {
|
||||
const normalizedCashBalance = Math.floor(Math.max(0, input.cashBalance));
|
||||
const normalizedOrderableCash = Math.floor(Math.max(0, input.orderableCash ?? 0));
|
||||
|
||||
if (normalizedCashBalance > 0 && normalizedOrderableCash > 0) {
|
||||
return {
|
||||
cashBalance: Math.min(normalizedCashBalance, normalizedOrderableCash),
|
||||
source: "min_of_both",
|
||||
originalCashBalance: normalizedCashBalance,
|
||||
originalOrderableCash: normalizedOrderableCash,
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedOrderableCash > 0) {
|
||||
return {
|
||||
cashBalance: normalizedOrderableCash,
|
||||
source: "orderable_cash",
|
||||
originalCashBalance: normalizedCashBalance,
|
||||
originalOrderableCash: normalizedOrderableCash,
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedCashBalance > 0) {
|
||||
return {
|
||||
cashBalance: normalizedCashBalance,
|
||||
source: "cash_balance",
|
||||
originalCashBalance: normalizedCashBalance,
|
||||
originalOrderableCash: normalizedOrderableCash,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
cashBalance: 0,
|
||||
source: "none",
|
||||
originalCashBalance: normalizedCashBalance,
|
||||
originalOrderableCash: normalizedOrderableCash,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateEffectiveDailyLossLimit(
|
||||
effectiveAllocationAmount: number,
|
||||
dailyLossPercent: number,
|
||||
dailyLossAmount: number,
|
||||
) {
|
||||
const safeDailyLossAmount = Math.floor(Math.max(0, dailyLossAmount));
|
||||
if (safeDailyLossAmount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const safeDailyLossPercent = clampNumber(dailyLossPercent, 0, 100);
|
||||
if (safeDailyLossPercent <= 0) {
|
||||
return safeDailyLossAmount;
|
||||
}
|
||||
|
||||
const limitByPercent = Math.floor(
|
||||
(Math.max(0, effectiveAllocationAmount) * safeDailyLossPercent) / 100,
|
||||
);
|
||||
return Math.min(safeDailyLossAmount, Math.max(0, limitByPercent));
|
||||
}
|
||||
|
||||
export function buildRiskEnvelope(input: RiskEnvelopeInput) {
|
||||
const blockedReasons: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (input.allocationAmount <= 0) {
|
||||
blockedReasons.push("투자 금액은 0보다 커야 합니다.");
|
||||
}
|
||||
|
||||
if (input.allocationPercent <= 0) {
|
||||
blockedReasons.push("투자 비율은 0%보다 커야 합니다.");
|
||||
}
|
||||
|
||||
if (input.dailyLossAmount <= 0) {
|
||||
blockedReasons.push("일일 손실 금액은 0보다 커야 합니다.");
|
||||
}
|
||||
|
||||
if (input.cashBalance <= 0) {
|
||||
blockedReasons.push("가용 자산이 0원이라 자동매매를 시작할 수 없습니다.");
|
||||
}
|
||||
|
||||
const effectiveAllocationAmount = calculateEffectiveAllocationAmount(
|
||||
input.cashBalance,
|
||||
input.allocationPercent,
|
||||
input.allocationAmount,
|
||||
);
|
||||
|
||||
const effectiveDailyLossLimit = calculateEffectiveDailyLossLimit(
|
||||
effectiveAllocationAmount,
|
||||
input.dailyLossPercent,
|
||||
input.dailyLossAmount,
|
||||
);
|
||||
|
||||
if (effectiveAllocationAmount <= 0) {
|
||||
blockedReasons.push("실적용 투자금이 0원으로 계산되었습니다.");
|
||||
}
|
||||
|
||||
if (effectiveDailyLossLimit <= 0) {
|
||||
blockedReasons.push("실적용 손실 한도가 0원으로 계산되었습니다.");
|
||||
}
|
||||
|
||||
if (input.allocationAmount > input.cashBalance) {
|
||||
warnings.push("입력한 투자금이 가용자산보다 커서 가용자산 한도로 자동 보정됩니다.");
|
||||
}
|
||||
|
||||
if (effectiveDailyLossLimit > effectiveAllocationAmount) {
|
||||
warnings.push("손실 금액이 투자금보다 큽니다. 손실 기준을 투자금 이하로 설정하는 것을 권장합니다.");
|
||||
}
|
||||
|
||||
if (input.dailyLossPercent <= 0) {
|
||||
warnings.push("손실 비율이 0%라 참고 비율 경고를 계산하지 않습니다.");
|
||||
}
|
||||
|
||||
const allocationReferenceAmount =
|
||||
input.allocationPercent > 0
|
||||
? (Math.max(0, input.cashBalance) * Math.max(0, input.allocationPercent)) / 100
|
||||
: 0;
|
||||
if (allocationReferenceAmount > 0 && input.allocationAmount > allocationReferenceAmount) {
|
||||
warnings.push(
|
||||
`입력 투자금이 비율 상한(${input.allocationPercent}%) 기준 ${Math.floor(allocationReferenceAmount).toLocaleString("ko-KR")}원을 초과해 비율 상한으로 적용됩니다.`,
|
||||
);
|
||||
}
|
||||
|
||||
const dailyLossReferenceAmount =
|
||||
input.dailyLossPercent > 0
|
||||
? (Math.max(0, effectiveAllocationAmount) * Math.max(0, input.dailyLossPercent)) / 100
|
||||
: 0;
|
||||
if (dailyLossReferenceAmount > 0 && input.dailyLossAmount > dailyLossReferenceAmount) {
|
||||
warnings.push(
|
||||
`입력 손실금이 참고 비율(${input.dailyLossPercent}%) 기준 ${Math.floor(dailyLossReferenceAmount).toLocaleString("ko-KR")}원을 초과합니다.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.allocationPercent > 100) {
|
||||
warnings.push("투자 비율이 100%를 초과했습니다. 가용자산 기준으로 자동 보정됩니다.");
|
||||
}
|
||||
|
||||
if (input.dailyLossPercent > 20) {
|
||||
warnings.push("일일 손실 비율이 높습니다. 기본값 2% 수준을 권장합니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: blockedReasons.length === 0,
|
||||
blockedReasons,
|
||||
warnings,
|
||||
cashBalance: Math.max(0, Math.floor(input.cashBalance)),
|
||||
effectiveAllocationAmount,
|
||||
effectiveDailyLossLimit,
|
||||
} satisfies AutotradeValidationResult;
|
||||
}
|
||||
|
||||
export function evaluateSignalBlockers(input: SignalGateInput) {
|
||||
const blockers: string[] = [];
|
||||
const { signal, strategy, validation, dailyOrderCount, lastOrderAtMs, nowMs } = input;
|
||||
|
||||
if (!validation.isValid) {
|
||||
blockers.push("리스크 검증이 통과되지 않았습니다.");
|
||||
}
|
||||
|
||||
if (!signal.reason.trim()) {
|
||||
blockers.push("AI 신호 근거(reason)가 비어 있습니다.");
|
||||
}
|
||||
|
||||
if (signal.confidence < strategy.confidenceThreshold) {
|
||||
blockers.push(
|
||||
`신뢰도 ${signal.confidence.toFixed(2)}가 임계치 ${strategy.confidenceThreshold.toFixed(2)}보다 낮습니다.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (signal.riskFlags.some((flag) => flag.toLowerCase().includes("block"))) {
|
||||
blockers.push("신호에 차단 플래그가 포함되어 있습니다.");
|
||||
}
|
||||
|
||||
if (dailyOrderCount >= strategy.maxDailyOrders) {
|
||||
blockers.push("일일 최대 주문 건수를 초과했습니다.");
|
||||
}
|
||||
|
||||
if (lastOrderAtMs && nowMs - lastOrderAtMs < strategy.cooldownSec * 1000) {
|
||||
blockers.push("종목 쿨다운 시간 안에서는 신규 주문을 차단합니다.");
|
||||
}
|
||||
|
||||
return blockers;
|
||||
}
|
||||
|
||||
export function resolveOrderQuantity(params: {
|
||||
side: "buy" | "sell";
|
||||
price: number;
|
||||
requestedQuantity?: number;
|
||||
effectiveAllocationAmount: number;
|
||||
maxOrderAmountRatio: number;
|
||||
unitCost?: number;
|
||||
}) {
|
||||
if (params.side === "sell") {
|
||||
const requestedQuantity = Math.max(0, Math.floor(params.requestedQuantity ?? 0));
|
||||
// 매도는 예산 제한이 아니라 보유/매도가능 수량 제한을 우선 적용합니다.
|
||||
return requestedQuantity > 0 ? requestedQuantity : 1;
|
||||
}
|
||||
|
||||
const price = Math.max(0, params.price);
|
||||
if (!price) return 0;
|
||||
|
||||
const unitCost = Math.max(price, params.unitCost ?? price);
|
||||
const effectiveAllocationAmount = Math.max(0, params.effectiveAllocationAmount);
|
||||
const maxOrderAmount =
|
||||
effectiveAllocationAmount * clampNumber(params.maxOrderAmountRatio, 0.05, 1);
|
||||
|
||||
const affordableQuantity = Math.floor(maxOrderAmount / unitCost);
|
||||
if (affordableQuantity <= 0) {
|
||||
// 국내주식은 1주 단위 주문이라, 전체 예산으로 1주를 살 수 있으면
|
||||
// 과도하게 낮은 주문 비율 때문에 0주로 막히지 않도록 최소 1주를 허용합니다.
|
||||
const fullBudgetQuantity = Math.floor(effectiveAllocationAmount / unitCost);
|
||||
if (fullBudgetQuantity <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!params.requestedQuantity || params.requestedQuantity <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(1, params.requestedQuantity), fullBudgetQuantity);
|
||||
}
|
||||
|
||||
if (!params.requestedQuantity || params.requestedQuantity <= 0) {
|
||||
return Math.max(1, affordableQuantity);
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(params.requestedQuantity, affordableQuantity));
|
||||
}
|
||||
424
lib/autotrade/strategy.ts
Normal file
424
lib/autotrade/strategy.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* [파일 역할]
|
||||
* 자동매매 기본 전략값/폴백 신호 생성 로직을 제공하는 핵심 유틸입니다.
|
||||
*
|
||||
* [주요 책임]
|
||||
* - AI 실패/비활성 상황에서도 동작할 fallback 전략 생성
|
||||
* - 시세 기반 fallback 신호(buy/sell/hold) 계산
|
||||
* - 자동매매 설정 기본값 제공
|
||||
*/
|
||||
|
||||
import {
|
||||
AUTOTRADE_DEFAULT_TECHNIQUES,
|
||||
type AutotradeCompiledStrategy,
|
||||
type AutotradeMarketSnapshot,
|
||||
type AutotradeSignalCandidate,
|
||||
type AutotradeSetupFormValues,
|
||||
type AutotradeTechniqueId,
|
||||
} from "@/features/autotrade/types/autotrade.types";
|
||||
import { clampNumber } from "@/lib/autotrade/risk";
|
||||
|
||||
interface CompileFallbackInput {
|
||||
prompt: string;
|
||||
selectedTechniques: AutotradeTechniqueId[];
|
||||
confidenceThreshold: number;
|
||||
}
|
||||
|
||||
export function createFallbackCompiledStrategy(
|
||||
input: CompileFallbackInput,
|
||||
): AutotradeCompiledStrategy {
|
||||
// [Step 1] 사용자가 입력한 프롬프트를 요약해 최소 전략 설명문을 만듭니다.
|
||||
const summary =
|
||||
input.prompt.trim().length > 0
|
||||
? `프롬프트 기반 전략: ${input.prompt.trim().slice(0, 120)}`
|
||||
: "기본 보수형 자동매매 전략";
|
||||
|
||||
// [Step 2] AI 없이도 동작 가능한 보수형 기본 파라미터를 반환합니다.
|
||||
return {
|
||||
provider: "fallback",
|
||||
summary,
|
||||
selectedTechniques: input.selectedTechniques,
|
||||
confidenceThreshold: clampNumber(input.confidenceThreshold, 0.45, 0.95),
|
||||
maxDailyOrders: resolveMaxDailyOrders(),
|
||||
cooldownSec: 60,
|
||||
maxOrderAmountRatio: 0.25,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createFallbackSignalCandidate(params: {
|
||||
strategy: AutotradeCompiledStrategy;
|
||||
snapshot: AutotradeMarketSnapshot;
|
||||
}): AutotradeSignalCandidate {
|
||||
// [Step 1] 선택한 기법별로 buy/sell 점수를 계산합니다.
|
||||
const { strategy, snapshot } = params;
|
||||
const activeTechniques =
|
||||
strategy.selectedTechniques.length > 0
|
||||
? strategy.selectedTechniques
|
||||
: AUTOTRADE_DEFAULT_TECHNIQUES;
|
||||
const reasons: string[] = [];
|
||||
let buyScore = 0;
|
||||
let sellScore = 0;
|
||||
|
||||
const shortMa = average(snapshot.recentPrices.slice(-3));
|
||||
const longMa = average(snapshot.recentPrices.slice(-7));
|
||||
const vwap = average(snapshot.recentPrices);
|
||||
const gapRate = snapshot.open
|
||||
? ((snapshot.currentPrice - snapshot.open) / snapshot.open) * 100
|
||||
: 0;
|
||||
|
||||
if (activeTechniques.includes("ma_crossover")) {
|
||||
if (shortMa > longMa * 1.001) {
|
||||
buyScore += 1;
|
||||
reasons.push("단기 이평이 중기 이평 위로 교차했습니다.");
|
||||
} else if (shortMa < longMa * 0.999) {
|
||||
sellScore += 1;
|
||||
reasons.push("단기 이평이 중기 이평 아래로 교차했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTechniques.includes("vwap_reversion") && vwap > 0) {
|
||||
if (snapshot.currentPrice < vwap * 0.995) {
|
||||
buyScore += 1;
|
||||
reasons.push("가격이 VWAP 대비 과매도 구간입니다.");
|
||||
} else if (snapshot.currentPrice > vwap * 1.005) {
|
||||
sellScore += 1;
|
||||
reasons.push("가격이 VWAP 대비 과매수 구간입니다.");
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTechniques.includes("volume_breakout")) {
|
||||
const volumeRatio =
|
||||
snapshot.volumeRatio ??
|
||||
(snapshot.accumulatedVolume > 0
|
||||
? snapshot.tradeVolume / Math.max(snapshot.accumulatedVolume / 120, 1)
|
||||
: 1);
|
||||
|
||||
if (volumeRatio > 1.8 && snapshot.changeRate > 0.25) {
|
||||
buyScore += 1;
|
||||
reasons.push("거래량 증가와 상승 모멘텀이 같이 나타났습니다.");
|
||||
}
|
||||
|
||||
if (volumeRatio > 1.8 && snapshot.changeRate < -0.25) {
|
||||
sellScore += 1;
|
||||
reasons.push("거래량 증가와 하락 모멘텀이 같이 나타났습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTechniques.includes("gap_breakout")) {
|
||||
if (gapRate > 0.8 && snapshot.currentPrice >= snapshot.high * 0.998) {
|
||||
buyScore += 1;
|
||||
reasons.push("상승 갭 이후 고점 돌파 구간입니다.");
|
||||
} else if (gapRate < -0.8 && snapshot.currentPrice <= snapshot.low * 1.002) {
|
||||
sellScore += 1;
|
||||
reasons.push("하락 갭 이후 저점 이탈 구간입니다.");
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTechniques.includes("orb")) {
|
||||
const nearHighBreak = snapshot.currentPrice >= snapshot.high * 0.999;
|
||||
const nearLowBreak = snapshot.currentPrice <= snapshot.low * 1.001;
|
||||
|
||||
if (nearHighBreak && snapshot.changeRate > 0) {
|
||||
buyScore += 1;
|
||||
reasons.push("시가 범위 상단 돌파 조건이 충족되었습니다.");
|
||||
}
|
||||
|
||||
if (nearLowBreak && snapshot.changeRate < 0) {
|
||||
sellScore += 1;
|
||||
reasons.push("시가 범위 하단 이탈 조건이 충족되었습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTechniques.includes("intraday_box_reversion")) {
|
||||
const boxReversion = resolveIntradayBoxReversionSignal(snapshot);
|
||||
|
||||
if (boxReversion.side === "buy") {
|
||||
buyScore += 1.2;
|
||||
reasons.push(boxReversion.reason);
|
||||
} else if (boxReversion.side === "sell") {
|
||||
sellScore += 1.2;
|
||||
reasons.push(boxReversion.reason);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTechniques.includes("intraday_breakout_scalp")) {
|
||||
const breakoutScalp = resolveIntradayBreakoutScalpSignal(snapshot);
|
||||
|
||||
if (breakoutScalp.side === "buy") {
|
||||
buyScore += 1.3;
|
||||
reasons.push(breakoutScalp.reason);
|
||||
} else if (breakoutScalp.side === "sell") {
|
||||
sellScore += 0.9;
|
||||
reasons.push(breakoutScalp.reason);
|
||||
}
|
||||
}
|
||||
|
||||
const riskFlags: string[] = [];
|
||||
if ((snapshot.marketDataLatencySec ?? 0) > 5) {
|
||||
riskFlags.push("market_data_stale");
|
||||
}
|
||||
|
||||
// [Step 2] 방향성이 부족하면 hold 신호를 우선 반환합니다.
|
||||
if (buyScore === 0 && sellScore === 0) {
|
||||
return {
|
||||
signal: "hold",
|
||||
confidence: 0.55,
|
||||
reason: "선택한 기법에서 유의미한 방향 신호가 아직 없습니다.",
|
||||
ttlSec: 20,
|
||||
riskFlags,
|
||||
source: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
const isBuy = buyScore >= sellScore;
|
||||
const scoreDiff = Math.abs(buyScore - sellScore);
|
||||
const confidence = clampNumber(
|
||||
0.58 + scoreDiff * 0.12 + Math.min(0.12, Math.abs(snapshot.changeRate) / 10),
|
||||
0.5,
|
||||
0.95,
|
||||
);
|
||||
|
||||
if (confidence < strategy.confidenceThreshold) {
|
||||
riskFlags.push("confidence_low");
|
||||
}
|
||||
|
||||
// [Step 3] 점수 우세 방향 + 신뢰도를 기반으로 주문 후보를 구성합니다.
|
||||
return {
|
||||
signal: isBuy ? "buy" : "sell",
|
||||
confidence,
|
||||
reason: reasons[0] ?? "복합 신호 점수가 기준치를 넘었습니다.",
|
||||
ttlSec: 20,
|
||||
riskFlags,
|
||||
proposedOrder: {
|
||||
symbol: snapshot.symbol,
|
||||
side: isBuy ? "buy" : "sell",
|
||||
orderType: "limit",
|
||||
price: snapshot.currentPrice,
|
||||
quantity: 1,
|
||||
},
|
||||
source: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSetupDefaults(): AutotradeSetupFormValues {
|
||||
const subscriptionCliVendor = resolveDefaultSubscriptionCliVendor();
|
||||
|
||||
return {
|
||||
aiMode: resolveDefaultAiMode(),
|
||||
subscriptionCliVendor,
|
||||
subscriptionCliModel: resolveDefaultSubscriptionCliModel(subscriptionCliVendor),
|
||||
prompt: "",
|
||||
selectedTechniques: [],
|
||||
allocationPercent: 10,
|
||||
allocationAmount: 500000,
|
||||
dailyLossPercent: 2,
|
||||
dailyLossAmount: 50000,
|
||||
confidenceThreshold: resolveDefaultConfidenceThreshold(),
|
||||
agreeStopOnExit: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDefaultAiMode(): AutotradeSetupFormValues["aiMode"] {
|
||||
const raw = (process.env.AUTOTRADE_AI_MODE ?? "auto").trim().toLowerCase();
|
||||
if (raw === "openai_api") return "openai_api";
|
||||
if (raw === "subscription_cli") return "subscription_cli";
|
||||
if (raw === "rule_fallback") return "rule_fallback";
|
||||
return "auto";
|
||||
}
|
||||
|
||||
function resolveDefaultSubscriptionCliVendor(): AutotradeSetupFormValues["subscriptionCliVendor"] {
|
||||
const raw = (process.env.AUTOTRADE_SUBSCRIPTION_CLI ?? "auto").trim().toLowerCase();
|
||||
if (raw === "codex") return "codex";
|
||||
if (raw === "gemini") return "gemini";
|
||||
return "auto";
|
||||
}
|
||||
|
||||
function resolveDefaultSubscriptionCliModel(vendor: AutotradeSetupFormValues["subscriptionCliVendor"]) {
|
||||
const commonModel = (process.env.AUTOTRADE_SUBSCRIPTION_CLI_MODEL ?? "").trim();
|
||||
if (commonModel) return commonModel;
|
||||
|
||||
if (vendor === "codex") {
|
||||
const codexModel = (process.env.AUTOTRADE_CODEX_MODEL ?? "").trim();
|
||||
return codexModel || "gpt-5-codex";
|
||||
}
|
||||
|
||||
if (vendor === "gemini") {
|
||||
const geminiModel = (process.env.AUTOTRADE_GEMINI_MODEL ?? "").trim();
|
||||
return geminiModel || "auto";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveMaxDailyOrders() {
|
||||
const raw = Number.parseInt(
|
||||
process.env.AUTOTRADE_MAX_DAILY_ORDERS_DEFAULT ?? "20",
|
||||
10,
|
||||
);
|
||||
if (!Number.isFinite(raw)) return 20;
|
||||
return clampNumber(raw, 1, 200);
|
||||
}
|
||||
|
||||
function resolveDefaultConfidenceThreshold() {
|
||||
const raw = Number.parseFloat(
|
||||
process.env.AUTOTRADE_CONFIDENCE_THRESHOLD_DEFAULT ?? "0.65",
|
||||
);
|
||||
if (!Number.isFinite(raw)) return 0.65;
|
||||
return clampNumber(raw, 0.45, 0.95);
|
||||
}
|
||||
|
||||
function average(values: number[]) {
|
||||
const normalized = values.filter((value) => Number.isFinite(value) && value > 0);
|
||||
if (normalized.length === 0) return 0;
|
||||
const sum = normalized.reduce((acc, value) => acc + value, 0);
|
||||
return sum / normalized.length;
|
||||
}
|
||||
|
||||
function resolveIntradayBoxReversionSignal(
|
||||
snapshot: AutotradeMarketSnapshot,
|
||||
): { side: "buy" | "sell" | null; reason: string } {
|
||||
const windowPrices = snapshot.recentPrices
|
||||
.slice(-12)
|
||||
.filter((price) => Number.isFinite(price) && price > 0);
|
||||
if (windowPrices.length < 6) {
|
||||
return { side: null, reason: "" };
|
||||
}
|
||||
|
||||
const boxHigh = Math.max(...windowPrices);
|
||||
const boxLow = Math.min(...windowPrices);
|
||||
const boxRange = boxHigh - boxLow;
|
||||
if (boxRange <= 0 || snapshot.currentPrice <= 0) {
|
||||
return { side: null, reason: "" };
|
||||
}
|
||||
|
||||
const dayRise = snapshot.changeRate;
|
||||
const boxRangeRate = boxRange / snapshot.currentPrice;
|
||||
const oscillationCount = countDirectionFlips(windowPrices);
|
||||
const isRisingDay = dayRise >= 0.8;
|
||||
const isBoxRange = boxRangeRate >= 0.003 && boxRangeRate <= 0.02;
|
||||
const isOscillating = oscillationCount >= 2;
|
||||
|
||||
if (!isRisingDay || !isBoxRange || !isOscillating) {
|
||||
return { side: null, reason: "" };
|
||||
}
|
||||
|
||||
const rangePosition = clampNumber(
|
||||
(snapshot.currentPrice - boxLow) / boxRange,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
if (rangePosition <= 0.28) {
|
||||
return {
|
||||
side: "buy",
|
||||
reason: `당일 상승(+${dayRise.toFixed(2)}%) 뒤 박스권 하단에서 반등 단타 조건이 확인됐습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (rangePosition >= 0.72) {
|
||||
return {
|
||||
side: "sell",
|
||||
reason: `당일 상승(+${dayRise.toFixed(2)}%) 뒤 박스권 상단에서 되돌림 단타 조건이 확인됐습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { side: null, reason: "" };
|
||||
}
|
||||
|
||||
function resolveIntradayBreakoutScalpSignal(
|
||||
snapshot: AutotradeMarketSnapshot,
|
||||
): { side: "buy" | "sell" | null; reason: string } {
|
||||
// [Step 1] 1분봉 최근 구간에서 추세/눌림/돌파 판단에 필요한 표본을 준비합니다.
|
||||
const prices = snapshot.recentPrices
|
||||
.slice(-18)
|
||||
.filter((price) => Number.isFinite(price) && price > 0);
|
||||
if (prices.length < 12 || snapshot.currentPrice <= 0) {
|
||||
return { side: null, reason: "" };
|
||||
}
|
||||
|
||||
const trendBase = prices.slice(0, prices.length - 6);
|
||||
const pullbackWindow = prices.slice(prices.length - 6, prices.length - 1);
|
||||
if (trendBase.length < 5 || pullbackWindow.length < 4) {
|
||||
return { side: null, reason: "" };
|
||||
}
|
||||
|
||||
const trendHigh = Math.max(...trendBase);
|
||||
const trendLow = Math.min(...trendBase);
|
||||
const pullbackHigh = Math.max(...pullbackWindow);
|
||||
const pullbackLow = Math.min(...pullbackWindow);
|
||||
const pullbackRange = pullbackHigh - pullbackLow;
|
||||
|
||||
if (trendHigh <= 0 || pullbackRange <= 0) {
|
||||
return { side: null, reason: "" };
|
||||
}
|
||||
|
||||
const trendRiseRate = ((trendHigh - trendLow) / trendLow) * 100;
|
||||
const pullbackDepthRate = ((trendHigh - pullbackLow) / trendHigh) * 100;
|
||||
const pullbackRangeRate = (pullbackRange / snapshot.currentPrice) * 100;
|
||||
const volumeRatio = snapshot.volumeRatio ?? 1;
|
||||
const tradeStrength = snapshot.tradeStrength ?? 100;
|
||||
const hasBidSupport =
|
||||
(snapshot.totalBidSize ?? 0) > (snapshot.totalAskSize ?? 0) * 1.02;
|
||||
const momentum = snapshot.intradayMomentum ?? 0;
|
||||
|
||||
// 추세 필터: 상승 구간인지 먼저 확인
|
||||
const isRisingTrend =
|
||||
trendRiseRate >= 0.7 && snapshot.changeRate >= 0.4 && momentum >= 0.15;
|
||||
|
||||
// 눌림 필터: 박스형 눌림인지 확인 (과도한 급락 제외)
|
||||
const isControlledPullback =
|
||||
pullbackDepthRate >= 0.2 &&
|
||||
pullbackDepthRate <= 1.6 &&
|
||||
pullbackRangeRate >= 0.12 &&
|
||||
pullbackRangeRate <= 0.9;
|
||||
|
||||
if (!isRisingTrend || !isControlledPullback) {
|
||||
return { side: null, reason: "" };
|
||||
}
|
||||
|
||||
// [Step 2] 눌림 상단 재돌파 + 거래량 재유입 조건이 맞으면 매수 우세로 판단합니다.
|
||||
const breakoutTrigger = snapshot.currentPrice >= pullbackHigh * 1.0007;
|
||||
const breakoutConfirmed = breakoutTrigger && volumeRatio >= 1.15;
|
||||
const orderflowConfirmed = tradeStrength >= 98 || hasBidSupport;
|
||||
|
||||
if (breakoutConfirmed && orderflowConfirmed) {
|
||||
return {
|
||||
side: "buy",
|
||||
reason: `상승 추세(+${trendRiseRate.toFixed(2)}%)에서 눌림 상단 재돌파(거래량비 ${volumeRatio.toFixed(2)})가 확인됐습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
// [Step 3] 재돌파 실패 후 눌림 하단 이탈 시 보수적으로 매도(또는 익절/청산) 신호를 냅니다.
|
||||
const failedBreakdown =
|
||||
snapshot.currentPrice <= pullbackLow * 0.9995 &&
|
||||
volumeRatio >= 1.05 &&
|
||||
(snapshot.netBuyExecutionCount ?? 0) < 0;
|
||||
|
||||
if (failedBreakdown) {
|
||||
return {
|
||||
side: "sell",
|
||||
reason: "눌림 하단 이탈로 상승 단타 시나리오가 약화되어 보수적 청산 신호를 냅니다.",
|
||||
};
|
||||
}
|
||||
|
||||
return { side: null, reason: "" };
|
||||
}
|
||||
|
||||
function countDirectionFlips(prices: number[]) {
|
||||
let flips = 0;
|
||||
let prevDirection = 0;
|
||||
|
||||
for (let i = 1; i < prices.length; i += 1) {
|
||||
const diff = prices[i] - prices[i - 1];
|
||||
const direction = diff > 0 ? 1 : diff < 0 ? -1 : 0;
|
||||
if (direction === 0) continue;
|
||||
|
||||
if (prevDirection !== 0 && direction !== prevDirection) {
|
||||
flips += 1;
|
||||
}
|
||||
prevDirection = direction;
|
||||
}
|
||||
|
||||
return flips;
|
||||
}
|
||||
Reference in New Issue
Block a user