using FluentAssertions; using IBKRTrader.Core.Capital; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Persistence; using IBKRTrader.Core.Persistence.Entities; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Trading; using NSubstitute; namespace IBKRTrader.Tests.Trading; [Trait("cat", "unit")] public class ExecutionServiceTests { private readonly IBrokerClient _broker = Substitute.For(); private readonly IRiskService _risk = Substitute.For(); private readonly IPortfolioService _portfolio = Substitute.For(); private readonly SettingsService _settings = new(); private readonly IDecisionJournal _journal = Substitute.For(); private readonly IOrderEventLog _orderLog = Substitute.For(); private readonly ICapitalService _capital = Substitute.For(); private static readonly BookId Book = BookId.ForModule("CT"); private ExecutionService CreateSut() => new(_broker, _risk, _portfolio, _settings, new LoggingService(), _journal, _orderLog, _capital); private static readonly TradeSignal BuySignal = new() { Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "CT" }; private void EnableTrading() => _settings.Settings.Trading.TradingEnabled = true; /// /// Kapital-Seam im Normalfall: Buch bekannt, Kontext leer, Bindung geht durch. Die Regeln des /// Kapitalmodells werden einzeln in CapitalTests geprueft – hier geht es nur darum, dass der /// Ausfuehrungspfad sie an der richtigen Stelle aufruft. /// private void ArrangeCapital(long reservationId = 1) { _capital.ResolveBook("CT").Returns(Book); _capital.BuildContextAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(ci => Task.FromResult(new RiskContext { Price = ci.ArgAt(2), NetLiquidation = ci.ArgAt(3).NetLiquidation })); _capital.ReserveAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new ReservationService.ReserveResult( new CoreReservation { Id = reservationId }, null, false))); } private void ArrangeHappyPath() { EnableTrading(); _broker.GetQuoteAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new Quote("AAPL", 100m, 99m, 101m))); _broker.GetAccountStateAsync(Arg.Any()) .Returns(Task.FromResult(new AccountState(10_000m, 10_000m))); _portfolio.GetModuleExposureAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(0m)); _portfolio.GetPositionQuantityAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(0)); ArrangeCapital(); _risk.Evaluate(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(RiskDecision.Approve(5)); _broker.PlaceOrderAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(OrderResult.Filled("O1", 5, 100m))); } [Fact] public async Task TradingDisabled_Skips_WithoutTouchingBroker() { // TradingEnabled ist standardmäßig false var result = await CreateSut().ExecuteAsync(BuySignal); result.Action.Should().Be("SKIP"); await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task NoQuote_Skips() { EnableTrading(); _broker.GetQuoteAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(null)); var result = await CreateSut().ExecuteAsync(BuySignal); result.Action.Should().Be("SKIP"); await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task RiskRejects_Skips_WithReason() { EnableTrading(); _broker.GetQuoteAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new Quote("AAPL", 100m, 99m, 101m))); _broker.GetAccountStateAsync(Arg.Any()) .Returns(Task.FromResult(new AccountState(10_000m, 10_000m))); ArrangeCapital(); _risk.Evaluate(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(RiskDecision.Reject("Modul-Limit überschritten.")); var result = await CreateSut().ExecuteAsync(BuySignal); result.Action.Should().Be("SKIP"); result.Reason.Should().Contain("Modul-Limit"); await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task HappyPath_PlacesOrder_AndBooksFill() { ArrangeHappyPath(); var result = await CreateSut().ExecuteAsync(BuySignal); result.Executed.Should().BeTrue(); result.Order!.OrderId.Should().Be("O1"); await _portfolio.Received(1).RecordFillAsync( "CT", InstrumentRef.ForStock("AAPL"), TradeSide.Buy, 5, 100m, "O1", Arg.Any(), Arg.Any()); } [Fact] public async Task HappyPath_MarketOrder_WhenNoLimitPrice() { ArrangeHappyPath(); await CreateSut().ExecuteAsync(BuySignal); await _broker.Received(1).PlaceOrderAsync( Arg.Is(o => o.Type == OrderType.Market && o.Quantity == 5), Arg.Any()); } [Fact] public async Task TradingDisabled_WritesSkippedDecision() { await CreateSut().ExecuteAsync(BuySignal); _journal.Received().Write(Arg.Is( d => d.Decision == IBKRTrader.Core.Persistence.Entities.TradeDecision.Skipped && d.Reason == IBKRTrader.Core.Persistence.Entities.DecisionReason.TradingDisabled)); } [Fact] public async Task HappyPath_PropagatesSignalId_AndJournalsExecuted() { ArrangeHappyPath(); var signal = new TradeSignal { Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "CT", SignalId = "sig-abc" }; await CreateSut().ExecuteAsync(signal); await _portfolio.Received(1).RecordFillAsync( "CT", InstrumentRef.ForStock("AAPL"), TradeSide.Buy, 5, 100m, "O1", "sig-abc", Arg.Any()); _journal.Received().Write(Arg.Is( d => d.SignalId == "sig-abc" && d.Decision == IBKRTrader.Core.Persistence.Entities.TradeDecision.Executed)); _orderLog.Received().Write(Arg.Is( e => e.SignalId == "sig-abc" && e.EventType == IBKRTrader.Core.Persistence.Entities.OrderEventType.Filled)); } [Fact] public async Task UnknownBook_IsRejected_BeforeAnyOrder() { // SourceModule kommt aus dem Modul und ist kein Ausweis: ein nicht registrierter Name // wuerde ein Buch ohne Cap erzeugen - also ein unbegrenztes. ArrangeHappyPath(); _capital.ResolveBook("CT").Returns((BookId?)null); var result = await CreateSut().ExecuteAsync(BuySignal); result.Action.Should().Be("SKIP"); result.Reason.Should().Contain("Unbekanntes Buch"); await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task CapitalRejects_NoOrderIsPlaced() { ArrangeHappyPath(); _capital.ReserveAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new ReservationService.ReserveResult( null, new LimitBreach(LimitKind.CashAvailable, "AAPL", 5000m, 1000m, 10, 1000m), false))); var result = await CreateSut().ExecuteAsync(BuySignal); result.Action.Should().Be("SKIP"); result.Reason.Should().Contain("Freies Bargeld"); await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task ExistingReservationForTheSameSignal_DoesNotPlaceASecondOrder() { // Der Fall, gegen den die Idempotenz gebaut ist: ein Reconnect schickt dasselbe Signal // erneut. Ohne diese Sperre laege dieselbe Order zweimal beim Broker. ArrangeHappyPath(); _capital.ReserveAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new ReservationService.ReserveResult( new CoreReservation { Id = 7 }, null, WasExisting: true))); var result = await CreateSut().ExecuteAsync(BuySignal); result.Action.Should().Be("SKIP"); await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task FailedOrder_ReleasesTheReservation() { // Sonst bliebe das Geld bis zum Fristablauf gebunden, obwohl gar keine Order existiert. ArrangeHappyPath(); _broker.PlaceOrderAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(OrderResult.Fail("Broker abgelehnt"))); await CreateSut().ExecuteAsync(BuySignal); await _capital.Received(1).CancelReservationAsync(1, Arg.Any(), Arg.Any()); } [Fact] public async Task FilledOrder_SettlesTheReservation() { ArrangeHappyPath(); await CreateSut().ExecuteAsync(BuySignal); // Reserviert wurde mit Puffer, ausgefuehrt zum echten Preis: 5 x 100 = 500 fliessen als // tatsaechlicher Bedarf zurueck, der Rest wird freigegeben. Die Order-ID gehoert dazu - // ohne sie kann der Abgleich beim Neustart keine Bindung einer Order zuordnen. await _capital.Received(1).SettleReservationAsync(1, 5, 500m, "O1", Arg.Any()); } [Fact] public async Task TheOrderCarriesTheSignalsCurrency_NotTheDefault() { // Am 2026-09-04 gegen das Paper-Konto beobachtet: geprueft und reserviert wurde SAP:EUR, // gehandelt wurde die SAP-ADR in New York, weil die OrderRequest aus Einzelfeldern gebaut // wurde und die Waehrung dabei auf USD zurueckfiel. Ohne Fehlermeldung. ArrangeHappyPath(); var signal = new TradeSignal { Symbol = "SAP", Side = TradeSide.Buy, SourceModule = "CT", Currency = "EUR" }; await CreateSut().ExecuteAsync(signal); await _broker.Received(1).PlaceOrderAsync( Arg.Is(o => o.Currency == "EUR" && o.Instrument.Key == "SAP:EUR"), Arg.Any()); } [Fact] public async Task TheOrderCarriesTheOptionContract() { ArrangeHappyPath(); var spec = new OptionSpec("AAPL", new DateOnly(2026, 9, 18), 150m, OptionRight.Put); var signal = new TradeSignal { Symbol = "AAPL", Side = TradeSide.Sell, SourceModule = "CT", Option = spec }; await CreateSut().ExecuteAsync(signal); await _broker.Received(1).PlaceOrderAsync( Arg.Is(o => o.Option == spec && o.Instrument.Kind == InstrumentKind.Option), Arg.Any()); } [Fact] public async Task OrderFails_ReturnsError_AndDoesNotBook() { ArrangeHappyPath(); _broker.PlaceOrderAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(OrderResult.Fail("Broker abgelehnt"))); var result = await CreateSut().ExecuteAsync(BuySignal); result.Action.Should().Be("ERROR"); result.Reason.Should().Contain("Broker abgelehnt"); await _portfolio.DidNotReceive().RecordFillAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } }