Files
Finlytic/FinlyticBot/Services/BotRiskSizingService.cs
T

143 lines
6.4 KiB
C#

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<BotRiskSizingService> _finlyticLogger;
public BotRiskSizingService(
ISettingsService settingsService,
IFinlyticLogger<BotRiskSizingService> finlyticLogger)
{
_settingsService = settingsService;
_finlyticLogger = finlyticLogger;
}
public async Task<SizingResult> 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);
}
}