전체적인 리팩토링

This commit is contained in:
2026-03-12 09:26:27 +09:00
parent 406af7408a
commit e51d767878
97 changed files with 13651 additions and 363 deletions

View 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;
}

View 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,
};
}

View 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
View 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
View 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
View 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;
}