using FluentAssertions; using IBKRTrader.Core.Budget; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Trading; using Microsoft.EntityFrameworkCore; namespace IBKRTrader.Tests.Trading; /// /// EF-gestützte Buchführung gegen die EF-InMemory-Datenbank (kein externer DB-Zugriff, /// deterministisch – zählt als Unit-Test). /// [Trait("cat", "unit")] public class PortfolioServiceTests { private sealed class InMemoryFactory : IDbContextFactory { private readonly DbContextOptions _options; public InMemoryFactory(DbContextOptions options) => _options = options; public CoreDbContext CreateDbContext() => new(_options); } private static PortfolioService CreateSut(out IDbContextFactory factory) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; factory = new InMemoryFactory(options); var logger = new LoggingService(); var history = new TradeHistoryService(factory, logger); var budget = new BudgetService(factory, logger); return new PortfolioService(factory, history, budget, logger); } [Fact] public async Task Buy_CreatesPosition_AndExposure_AndBudget_AndHistory() { var sut = CreateSut(out var factory); await sut.RecordFillAsync("CT", "AAPL", TradeSide.Buy, 5, 100m, "O1"); (await sut.GetPositionQuantityAsync("CT", "AAPL")).Should().Be(5); (await sut.GetModuleExposureAsync("CT")).Should().Be(500m); await using var db = await factory.CreateDbContextAsync(); db.TradeHistory.Should().ContainSingle(); (await db.Budgets.FindAsync("CT"))!.UsedBudget.Should().Be(500m); } [Fact] public async Task Buy_Twice_AveragesPrice() { var sut = CreateSut(out _); await sut.RecordFillAsync("CT", "AAPL", TradeSide.Buy, 10, 100m, "O1"); await sut.RecordFillAsync("CT", "AAPL", TradeSide.Buy, 10, 120m, "O2"); (await sut.GetPositionQuantityAsync("CT", "AAPL")).Should().Be(20); // Durchschnitt: (10*100 + 10*120) / 20 = 110 var positions = await sut.GetPositionsAsync("CT"); positions.Single().AvgPrice.Should().Be(110m); } [Fact] public async Task Sell_Partial_ReducesQuantity() { var sut = CreateSut(out _); await sut.RecordFillAsync("CT", "AAPL", TradeSide.Buy, 10, 100m, "O1"); await sut.RecordFillAsync("CT", "AAPL", TradeSide.Sell, 4, 130m, "O2"); (await sut.GetPositionQuantityAsync("CT", "AAPL")).Should().Be(6); } [Fact] public async Task Sell_Full_RemovesPosition() { var sut = CreateSut(out _); await sut.RecordFillAsync("CT", "AAPL", TradeSide.Buy, 10, 100m, "O1"); await sut.RecordFillAsync("CT", "AAPL", TradeSide.Sell, 10, 130m, "O2"); (await sut.GetPositionQuantityAsync("CT", "AAPL")).Should().Be(0); (await sut.GetPositionsAsync("CT")).Should().BeEmpty(); } [Fact] public async Task Exposure_IsIsolatedPerModule() { var sut = CreateSut(out _); await sut.RecordFillAsync("CT", "AAPL", TradeSide.Buy, 5, 100m, "O1"); await sut.RecordFillAsync("XX", "MSFT", TradeSide.Buy, 2, 200m, "O2"); (await sut.GetModuleExposureAsync("CT")).Should().Be(500m); (await sut.GetModuleExposureAsync("XX")).Should().Be(400m); } }