namespace IBKRTrader.Core.Trading; /// /// Reine Risiko-/Sizing-Logik – keine externen Abhängigkeiten, vollständig unit-testbar. /// /// Regeln: /// - Kauf: Nominalwert = min(Wunsch, Kontowert × MaxTradePercent); Stückzahl = floor(Nominal / Kurs). /// Ablehnung bei ungültigem Kurs, Stückzahl < 1, Überschreitung des Modul-Limits /// oder zu hoher Slippage (bei Limit-Order). /// - Verkauf: schließt die vorhandene Position (Stückzahl = gehaltene Menge); /// Ablehnung, wenn keine Position vorhanden ist. /// public sealed class RiskService : IRiskService { public RiskDecision Evaluate(TradeSignal signal, RiskContext context, RiskParameters risk) { if (context.Price <= 0) return RiskDecision.Reject("Ungültiger Kurs (<= 0)."); if (SlippageTooHigh(signal, context, risk, out var slipReason)) return RiskDecision.Reject(slipReason); return signal.Side == TradeSide.Sell ? EvaluateSell(context) : EvaluateBuy(context, signal, risk); } private static RiskDecision EvaluateSell(RiskContext context) { if (context.ExistingQuantity <= 0) return RiskDecision.Reject("Keine Position zum Verkauf vorhanden."); return RiskDecision.Approve(context.ExistingQuantity, "Verkauf schließt Position."); } private static RiskDecision EvaluateBuy(RiskContext context, TradeSignal signal, RiskParameters risk) { if (context.NetLiquidation <= 0) return RiskDecision.Reject("Kontowert unbekannt oder 0."); var maxNotional = context.NetLiquidation * (risk.MaxTradePercent / 100m); var notional = signal.SuggestedNotional is { } wish && wish > 0 ? Math.Min(wish, maxNotional) : maxNotional; var quantity = (int)Math.Floor(notional / context.Price); if (quantity < 1) return RiskDecision.Reject("Positionsgröße < 1 Stück bei aktuellem Kurs/Budget."); var projectedExposure = context.ModuleExposure + quantity * context.Price; var moduleLimit = context.NetLiquidation * (risk.MaxPositionPercentPerModule / 100m); if (projectedExposure > moduleLimit) return RiskDecision.Reject( $"Modul-Limit überschritten ({projectedExposure:F0} > {moduleLimit:F0})."); return RiskDecision.Approve(quantity, $"{quantity} Stück freigegeben."); } private static bool SlippageTooHigh( TradeSignal signal, RiskContext context, RiskParameters risk, out string reason) { reason = ""; if (signal.LimitPrice is not { } limit || limit <= 0) return false; var deviationPct = Math.Abs(context.Price - limit) / limit * 100m; if (deviationPct > risk.MaxSlippagePercent) { reason = $"Slippage zu hoch ({deviationPct:F1}% > {risk.MaxSlippagePercent:F1}%)."; return true; } return false; } }