전체적인 리팩토링
This commit is contained in:
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));
|
||||
}
|
||||
Reference in New Issue
Block a user