/** * [파일 역할] * 자동매매 기본 전략값/폴백 신호 생성 로직을 제공하는 핵심 유틸입니다. * * [주요 책임] * - 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; }