290 lines
9.3 KiB
TypeScript
290 lines
9.3 KiB
TypeScript
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));
|
|
}
|