using FluentAssertions; using IBKRTrader.Core.Trading; namespace IBKRTrader.Tests.Trading; [Trait("cat", "unit")] public class RiskServiceTests { private readonly RiskService _risk = new(); // MaxTrade 5 %, MaxModul 20 %, MaxSlippage 5 % private static readonly RiskParameters DefaultParams = new(5m, 20m, 5m); private static TradeSignal Buy(decimal? limit = null, decimal? notional = null) => new() { Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "CT", LimitPrice = limit, SuggestedNotional = notional }; private static TradeSignal Sell() => new() { Symbol = "AAPL", Side = TradeSide.Sell, SourceModule = "CT" }; [Fact] public void Buy_SizesByMaxTradePercent() { // NetLiq 10.000 × 5 % = 500 max Nominal; Kurs 100 → 5 Stück var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m }; var d = _risk.Evaluate(Buy(), ctx, DefaultParams); d.Approved.Should().BeTrue(); d.Quantity.Should().Be(5); } [Fact] public void Buy_UsesSuggestedNotional_WhenSmallerThanMax() { // Wunsch 200 < Max 500; Kurs 100 → 2 Stück var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m }; var d = _risk.Evaluate(Buy(notional: 200m), ctx, DefaultParams); d.Quantity.Should().Be(2); } [Fact] public void Buy_InvalidPrice_Rejected() { var ctx = new RiskContext { Price = 0m, NetLiquidation = 10_000m }; _risk.Evaluate(Buy(), ctx, DefaultParams).Approved.Should().BeFalse(); } [Fact] public void Buy_ZeroAccount_Rejected() { var ctx = new RiskContext { Price = 100m, NetLiquidation = 0m }; _risk.Evaluate(Buy(), ctx, DefaultParams).Approved.Should().BeFalse(); } [Fact] public void Buy_QuantityBelowOne_Rejected() { // NetLiq 100 × 5 % = 5 max Nominal; Kurs 100 → 0 Stück var ctx = new RiskContext { Price = 100m, NetLiquidation = 100m }; var d = _risk.Evaluate(Buy(), ctx, DefaultParams); d.Approved.Should().BeFalse(); d.Reason.Should().Contain("< 1"); } [Fact] public void Buy_ExceedsModuleLimit_Rejected() { // MaxTrade 50 % → 5.000 Nominal, Kurs 100 → 50 Stück = 5.000 // Modul-Limit 20 % × 10.000 = 2.000 → abgelehnt var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m }; var pars = new RiskParameters(50m, 20m, 5m); var d = _risk.Evaluate(Buy(), ctx, pars); d.Approved.Should().BeFalse(); d.Reason.Should().Contain("Modul-Limit"); } [Fact] public void Buy_ExistingExposureCountsTowardModuleLimit() { // Kurs 100, Max 5 % → 5 Stück (500). Bereits 1.700 Exposure. // Projektiert 2.200 > Limit 2.000 → abgelehnt. var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m, ModuleExposure = 1_700m }; _risk.Evaluate(Buy(), ctx, DefaultParams).Approved.Should().BeFalse(); } [Fact] public void Buy_LimitOrder_SlippageTooHigh_Rejected() { // Limit 100, Kurs 110 → 10 % > 5 % var ctx = new RiskContext { Price = 110m, NetLiquidation = 10_000m }; var d = _risk.Evaluate(Buy(limit: 100m), ctx, DefaultParams); d.Approved.Should().BeFalse(); d.Reason.Should().Contain("Slippage"); } [Fact] public void Buy_LimitOrder_SlippageWithinTolerance_Approved() { // Limit 100, Kurs 104 → 4 % < 5 % var ctx = new RiskContext { Price = 104m, NetLiquidation = 10_000m }; _risk.Evaluate(Buy(limit: 100m), ctx, DefaultParams).Approved.Should().BeTrue(); } [Fact] public void Sell_WithPosition_ClosesQuantity() { var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m, ExistingQuantity = 12 }; var d = _risk.Evaluate(Sell(), ctx, DefaultParams); d.Approved.Should().BeTrue(); d.Quantity.Should().Be(12); } [Fact] public void Sell_WithoutPosition_Rejected() { var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m, ExistingQuantity = 0 }; var d = _risk.Evaluate(Sell(), ctx, DefaultParams); d.Approved.Should().BeFalse(); d.Reason.Should().Contain("Keine Position"); } }