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