using System; using System.Threading; using System.Threading.Tasks; using FinlyticBot.Util; using FinlyticCore.Models.Trades; using FinlyticCore.Services; namespace FinlyticBot.Services; public class BotRiskSizingService : IBotRiskSizingService { private readonly ISettingsService _settingsService; private readonly IFinlyticLogger _finlyticLogger; public BotRiskSizingService( ISettingsService settingsService, IFinlyticLogger finlyticLogger) { _settingsService = settingsService; _finlyticLogger = finlyticLogger; } public async Task EvaluateAndSizeTradeAsync( TradeProposalDto proposal, decimal accountEquity, int currentOpenTradesCount, decimal todayRealizedLossPercent, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(proposal); // 1. Check Master Bot Switch bool isEnabled = await _settingsService.GetSettingAsync(SettingKeys.IsEnabled, ct); if (!isEnabled) { return new SizingResult(false, "FinlyticBot is currently disabled in settings.", 0, 0, 0, 0); } // 2. Check Daily Drawdown Circuit Breaker double dailyLossLimit = await _settingsService.GetSettingAsync(SettingKeys.DailyLossLimitPercent, ct); if (todayRealizedLossPercent >= (decimal)dailyLossLimit) { await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel, "[RiskEngine] Circuit breaker triggered! Today's realized loss {Loss:F2}% >= limit {Limit:F2}%. Rejecting trade {TradeId}.", todayRealizedLossPercent, dailyLossLimit, proposal.TradeId); return new SizingResult(false, $"Daily loss limit reached ({todayRealizedLossPercent:F2}% >= {dailyLossLimit:F2}%).", 0, 0, 0, 0); } // 3. Check Max Open Trades Limit int maxOpenTrades = await _settingsService.GetSettingAsync(SettingKeys.MaxOpenTrades, ct); if (currentOpenTradesCount >= maxOpenTrades) { return new SizingResult(false, $"Max concurrent open positions reached ({currentOpenTradesCount}/{maxOpenTrades}).", 0, 0, 0, 0); } // 4. Validate CRV (Chance-Risiko-Verhältnis) double minCrv = await _settingsService.GetSettingAsync(SettingKeys.MinCrv, ct); decimal calculatedCrv = proposal.RiskRewardRatio ?? 0; if (calculatedCrv <= 0 && proposal.EntryPrice > 0 && proposal.StopLoss > 0 && proposal.TakeProfit > 0) { decimal slDist = Math.Abs(proposal.EntryPrice - proposal.StopLoss); decimal tpDist = Math.Abs(proposal.TakeProfit - proposal.EntryPrice); if (slDist > 0) { calculatedCrv = Math.Round(tpDist / slDist, 2); } } if (calculatedCrv < (decimal)minCrv) { await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, "[RiskEngine] Trade {Symbol} rejected: CRV {Crv:F2} below threshold {MinCrv:F2}", proposal.Symbol, calculatedCrv, minCrv); return new SizingResult(false, $"CRV {calculatedCrv:F2} below minimum threshold {minCrv:F2}.", 0, 0, 0, calculatedCrv); } // 5. Validate Win-Rate double minWinRate = await _settingsService.GetSettingAsync(SettingKeys.MinWinRate, ct); if (proposal.WinRate < minWinRate) { await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, "[RiskEngine] Trade {Symbol} rejected: Win-Rate {WinRate:F1}% below threshold {MinWinRate:F1}%", proposal.Symbol, proposal.WinRate, minWinRate); return new SizingResult(false, $"Win-Rate {proposal.WinRate:F1}% below minimum threshold {minWinRate:F1}%.", 0, 0, 0, calculatedCrv); } // 6. Validate VIX Threshold double maxVix = await _settingsService.GetSettingAsync(SettingKeys.MaxVixThreshold, ct); if (proposal.VixValue > (decimal)maxVix) { return new SizingResult(false, $"VIX {proposal.VixValue:F1} exceeds maximum volatility threshold {maxVix:F1}.", 0, 0, 0, calculatedCrv); } // 7. Calculate Position Sizing (Fixed Fractional Sizing) if (accountEquity <= 0) { return new SizingResult(false, "Account equity is zero or negative.", 0, 0, 0, calculatedCrv); } double riskPercent = await _settingsService.GetSettingAsync(SettingKeys.RiskPerTradePercent, ct); decimal maxRiskAmount = accountEquity * ((decimal)riskPercent / 100m); decimal priceRiskPerUnit = Math.Abs(proposal.EntryPrice - proposal.StopLoss); if (priceRiskPerUnit <= 0) { return new SizingResult(false, "Stop loss cannot be identical to entry price.", 0, 0, 0, calculatedCrv); } decimal calculatedQty = Math.Floor(maxRiskAmount / priceRiskPerUnit); if (calculatedQty <= 0) { // Allow fractional share if total position is at least 10$ calculatedQty = Math.Round(maxRiskAmount / priceRiskPerUnit, 2); if (calculatedQty <= 0) { return new SizingResult(false, "Calculated order quantity is 0 (account equity too small for Stop Loss distance).", 0, 0, 0, calculatedCrv); } } decimal totalPositionValue = calculatedQty * proposal.EntryPrice; // 8. Cap against Max Single Position Cap double maxCap = await _settingsService.GetSettingAsync(SettingKeys.MaxSinglePositionCap, ct); if (totalPositionValue > (decimal)maxCap && proposal.EntryPrice > 0) { calculatedQty = Math.Floor((decimal)maxCap / proposal.EntryPrice); totalPositionValue = calculatedQty * proposal.EntryPrice; if (calculatedQty <= 0) { return new SizingResult(false, "Position size exceeds maximum position cap.", 0, 0, 0, calculatedCrv); } } decimal actualRiskAmount = calculatedQty * priceRiskPerUnit; await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, "[RiskEngine] Sizing APPROVED for {Symbol}: Qty={Qty}, PositionVal=${PosVal:F2}, Risk=${Risk:F2} ({RiskPct:F1}%), CRV={Crv:F2}", proposal.Symbol, calculatedQty, totalPositionValue, actualRiskAmount, riskPercent, calculatedCrv); return new SizingResult(true, null, calculatedQty, totalPositionValue, actualRiskAmount, calculatedCrv); } }