using FluentAssertions; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Orders; using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Persistence.Entities; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Trading; using Microsoft.EntityFrameworkCore; using NSubstitute; namespace IBKRTrader.Tests.Orders; [Trait("cat", "unit")] public class OrderManagerTests { private sealed class InMemoryFactory : IDbContextFactory { private readonly DbContextOptions _options; public InMemoryFactory(DbContextOptions options) => _options = options; public CoreDbContext CreateDbContext() => new(_options); } private static (OrderManager Sut, IBrokerOrderBook OrderBook, IDbContextFactory Dbf, SettingsService Settings) CreateSut() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var factory = new InMemoryFactory(options); var orderBook = Substitute.For(); var settings = new SettingsService(); var logger = new LoggingService(); var sut = new OrderManager(orderBook, factory, settings, logger); return (sut, orderBook, factory, settings); } [Fact] public async Task GetOrderOverviewAsync_IdentifiesBackedAndUnbackedOrders() { var (sut, orderBook, dbf, _) = CreateSut(); // 1. Broker-Orders vorbereiten var bo1 = new BrokerOrder( OrderId: 101, PermId: 1001, Instrument: InstrumentRef.ForStock("AAPL"), Side: TradeSide.Buy, Quantity: 10, Filled: 0, Remaining: 10, OrderType: "LMT", LimitPrice: 150m, Status: "Submitted", Account: "U123"); var bo2 = new BrokerOrder( OrderId: 102, PermId: 1002, Instrument: InstrumentRef.ForStock("MSFT"), Side: TradeSide.Buy, Quantity: 5, Filled: 0, Remaining: 5, OrderType: "LMT", LimitPrice: 400m, Status: "Submitted", Account: "U123"); orderBook.GetOpenOrdersAsync(Arg.Any()) .Returns([bo1, bo2]); // 2. Eigene Kapitalbindung nur für bo1 anlegen await using (var db = dbf.CreateDbContext()) { db.Reservations.Add(new CoreReservation { Id = 1, SignalId = "sig-1", Book = "CT", Side = "BUY", Quantity = 10, ContractKey = "AAPL", Amount = 1500m, Currency = "USD", State = ReservationState.Submitted, BrokerOrderId = "101", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }); await db.SaveChangesAsync(); } // Act var overview = await sut.GetOrderOverviewAsync(); // Assert overview.BrokerReachable.Should().BeTrue(); overview.Orders.Should().HaveCount(2); var managed1 = overview.Orders.First(o => o.BrokerOrder.OrderId == 101); managed1.IsBackedByReservation.Should().BeTrue(); managed1.ReservationId.Should().Be(1); managed1.Book.Should().Be("CT"); var managed2 = overview.Orders.First(o => o.BrokerOrder.OrderId == 102); managed2.IsBackedByReservation.Should().BeFalse(); managed2.ReservationId.Should().BeNull(); overview.UnbackedOrders.Should().HaveCount(1); overview.UnbackedOrders.Single().BrokerOrder.OrderId.Should().Be(102); } [Fact] public async Task CancelOrderAsync_DelegatesToBrokerOrderBook() { var (sut, orderBook, _, _) = CreateSut(); orderBook.CancelOrderAsync(101, Arg.Any()).Returns(true); var result = await sut.CancelOrderAsync(101); result.Success.Should().BeTrue(); result.Message.Should().Contain("101"); await orderBook.Received(1).CancelOrderAsync(101, Arg.Any()); } [Fact] public async Task CancelAllOrdersAsync_RejectsWhenNotPaperMode() { var (sut, orderBook, _, settings) = CreateSut(); settings.Settings.Trading.Mode = "Live"; var result = await sut.CancelAllOrdersAsync(); result.Success.Should().BeFalse(); result.Message.Should().Contain("Handelsmodus"); await orderBook.DidNotReceive().GetOpenOrdersAsync(Arg.Any()); } [Fact] public async Task CancelAllOrdersAsync_CancelsAllOrdersInPaperMode() { var (sut, orderBook, _, settings) = CreateSut(); settings.Settings.Trading.Mode = "Paper"; var bo1 = new BrokerOrder( OrderId: 201, PermId: 2001, Instrument: InstrumentRef.ForStock("AAPL"), Side: TradeSide.Buy, Quantity: 10, Filled: 0, Remaining: 10, OrderType: "LMT", LimitPrice: 150m, Status: "Submitted", Account: "U123"); var bo2 = new BrokerOrder( OrderId: 202, PermId: 2002, Instrument: InstrumentRef.ForStock("MSFT"), Side: TradeSide.Buy, Quantity: 5, Filled: 0, Remaining: 5, OrderType: "LMT", LimitPrice: 400m, Status: "Submitted", Account: "U123"); orderBook.GetOpenOrdersAsync(Arg.Any()).Returns([bo1, bo2]); orderBook.CancelOrderAsync(Arg.Any(), Arg.Any()).Returns(true); var result = await sut.CancelAllOrdersAsync(); result.Success.Should().BeTrue(); result.Cancelled.Should().Be(2); result.Total.Should().Be(2); await orderBook.Received(1).CancelOrderAsync(201, Arg.Any()); await orderBook.Received(1).CancelOrderAsync(202, Arg.Any()); } [Fact] public async Task GetOrderOverviewAsync_WhenReservationHasNoBrokerOrderId_TreatedAsUnbacked() { var (sut, orderBook, dbf, _) = CreateSut(); var bo = new BrokerOrder( OrderId: 301, PermId: 3001, Instrument: InstrumentRef.ForStock("NVDA"), Side: TradeSide.Buy, Quantity: 20, Filled: 0, Remaining: 20, OrderType: "LMT", LimitPrice: 120m, Status: "Submitted", Account: "U123"); orderBook.GetOpenOrdersAsync(Arg.Any()).Returns([bo]); await using (var db = dbf.CreateDbContext()) { db.Reservations.Add(new CoreReservation { Id = 10, SignalId = "sig-nvda", Book = "CT", Side = "BUY", Quantity = 20, ContractKey = "NVDA", Amount = 2400m, Currency = "USD", State = ReservationState.Reserved, BrokerOrderId = null, // noch keine Order-ID zugewiesen CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }); await db.SaveChangesAsync(); } var overview = await sut.GetOrderOverviewAsync(); overview.BrokerReachable.Should().BeTrue(); overview.Orders.Should().HaveCount(1); var mo = overview.Orders.Single(); mo.IsBackedByReservation.Should().BeFalse(); overview.UnbackedOrders.Should().HaveCount(1); } [Fact] public async Task GetOrderOverviewAsync_HandlesBrokerException_Gracefully() { var (sut, orderBook, _, _) = CreateSut(); orderBook.GetOpenOrdersAsync(Arg.Any()) .Returns>(_ => throw new InvalidOperationException("Broker offline")); var overview = await sut.GetOrderOverviewAsync(); overview.BrokerReachable.Should().BeFalse(); overview.Orders.Should().BeEmpty(); } [Fact] public async Task CancelOrderAsync_WhenBrokerReturnsFalse_ReportsFailure() { var (sut, orderBook, _, _) = CreateSut(); orderBook.CancelOrderAsync(999, Arg.Any()).Returns(false); var result = await sut.CancelOrderAsync(999); result.Success.Should().BeFalse(); result.Message.Should().Contain("NICHT bestätigt"); } }