using FluentAssertions; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Persistence; 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 ExecutionService CreateSut() => new(_broker, _risk, _portfolio, _settings, new LoggingService(), _journal, _orderLog); private static readonly TradeSignal BuySignal = new() { Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "CT" }; private void EnableTrading() => _settings.Settings.Trading.TradingEnabled = true; 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)); _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))); _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", "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", "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 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()); } }