using FluentAssertions; using IBKRTrader.Core.Capital; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Persistence.Entities; using IBKRTrader.Core.Positions; using IBKRTrader.Core.Trading; using Microsoft.EntityFrameworkCore; using NSubstitute; namespace IBKRTrader.Tests.Positions; /// /// Die Positionsverwaltung (#485). Geprüft wird gegen die EF-InMemory-Datenbank; Broker und /// Buchführung sind gemockt. /// /// Der Schwerpunkt liegt auf dem Umhängen. Es ist die einzige Handlung hier, die Daten /// verändert – und die einzige, bei der ein Fehler wie ein Handel aussähe, den niemand ausgelöst /// hat. /// [Trait("cat", "unit")] public class PositionManagerTests { private sealed class InMemoryFactory(DbContextOptions options) : IDbContextFactory { public CoreDbContext CreateDbContext() => new(options); } private static readonly InstrumentRef Sap = InstrumentRef.ForStock("SAP", "EUR"); private static readonly BookId Ct = BookId.ForModule("CT"); private readonly IPortfolioService _portfolio = Substitute.For(); private readonly IBrokerPortfolioReader _broker = Substitute.For(); private PositionManager CreateSut(out IDbContextFactory factory) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; factory = new InMemoryFactory(options); var logger = new LoggingService(); return new PositionManager(factory, _portfolio, _broker, new TradingRuleService(factory, logger), logger); } private static CorePosition Row(string book, int quantity, decimal avgPrice) => new() { Module = book, ContractKey = Sap.Key, Symbol = "SAP", Currency = "EUR", Quantity = quantity, AvgPrice = avgPrice, Multiplier = 1, UpdatedAt = DateTime.UtcNow }; private static async Task SeedAsync(IDbContextFactory factory, params CorePosition[] rows) { await using var db = factory.CreateDbContext(); db.Positions.AddRange(rows); await db.SaveChangesAsync(); } // ─── Umhängen ──────────────────────────────────────────────────────────────── [Fact] public async Task Reassign_MovesTheBook_ButNeverTheQuantity() { var sut = CreateSut(out var factory); await SeedAsync(factory, Row(BookId.Unassigned.Value, 100, 230m)); var result = await sut.ReassignAsync( Sap, BookId.Unassigned, BookId.Manual, "beim Aufräumen wiedererkannt"); result.Success.Should().BeTrue(); await using var db = factory.CreateDbContext(); var rows = await db.Positions.ToListAsync(); rows.Should().ContainSingle("es entsteht keine zweite Zeile"); rows[0].Module.Should().Be(BookId.Manual.Value); rows[0].Quantity.Should().Be(100, "umgebucht wird die Zuordnung, nicht der Bestand"); rows[0].AvgPrice.Should().Be(230m, "ein neuer Einstand wäre eine erfundene Zahl"); } [Fact] public async Task Reassign_WithoutAReason_IsRefused() { // Eine Umbuchung ohne Begründung ist ein Monat später nicht mehr erklärbar – und sie ist // der einzige Weg, auf dem Bestand ohne Order das Buch wechselt. var sut = CreateSut(out var factory); await SeedAsync(factory, Row(BookId.Unassigned.Value, 100, 230m)); var result = await sut.ReassignAsync(Sap, BookId.Unassigned, BookId.Manual, " "); result.Success.Should().BeFalse(); result.Message.Should().Contain("Begründung"); } [Fact] public async Task Reassign_FromTheWrongBook_ChangesNothing() { // Das Quellbuch ist nicht Zierde, sondern Schutz: Wer sich in der Zeile vertut, soll eine // Absage bekommen und nicht eine fremde Position verschieben. var sut = CreateSut(out var factory); await SeedAsync(factory, Row(BookId.Unassigned.Value, 100, 230m)); var result = await sut.ReassignAsync(Sap, Ct, BookId.Manual, "Tippfehler"); result.Success.Should().BeFalse(); result.Message.Should().Contain("CT"); await using var db = factory.CreateDbContext(); (await db.Positions.SingleAsync()).Module.Should().Be(BookId.Unassigned.Value); } [Fact] public async Task Reassign_IntoABookThatAlreadyHoldsIt_MergesWithWeightedCost() { var sut = CreateSut(out var factory); await SeedAsync(factory, Row(BookId.Unassigned.Value, 100, 200m), Row(BookId.Manual.Value, 100, 300m)); var result = await sut.ReassignAsync(Sap, BookId.Unassigned, BookId.Manual, "zusammenlegen"); result.Success.Should().BeTrue(); await using var db = factory.CreateDbContext(); var row = await db.Positions.SingleAsync(); row.Module.Should().Be(BookId.Manual.Value); row.Quantity.Should().Be(200); row.AvgPrice.Should().Be(250m, "mengengewichtet aus 100x200 und 100x300"); } [Fact] public async Task Reassign_AgainstAnOppositePosition_IsRefused_BecauseThatWouldBeATrade() { // 100 long nach einem Buch mit 100 short zu schieben, ergäbe eine Position von null – also // eine Glattstellung. Die gehört über eine Order gemacht, mit Preis und Historie. var sut = CreateSut(out var factory); await SeedAsync(factory, Row(BookId.Unassigned.Value, 100, 200m), Row(BookId.Manual.Value, -100, 300m)); var result = await sut.ReassignAsync(Sap, BookId.Unassigned, BookId.Manual, "zusammenlegen"); result.Success.Should().BeFalse(); result.Message.Should().Contain("Glattstellung"); await using var db = factory.CreateDbContext(); (await db.Positions.CountAsync()).Should().Be(2, "es wurde nichts angefasst"); } [Fact] public async Task Reassign_ToTheSameBook_IsRefused() { var sut = CreateSut(out var factory); await SeedAsync(factory, Row(BookId.Manual.Value, 100, 230m)); (await sut.ReassignAsync(Sap, BookId.Manual, BookId.Manual, "egal")) .Success.Should().BeFalse(); } // ─── Überblick ─────────────────────────────────────────────────────────────── [Fact] public async Task Overview_WithoutABroker_LeavesTheComparisonEmpty_InsteadOfClaimingZero() { // Der wichtigste Fall der ganzen Ansicht: Ohne Verbindung darf keine Abweichung behauptet // werden. Stünde dort "Broker: 0", sähe jede Störung aus wie ein leergeräumtes Konto. var sut = CreateSut(out _); _portfolio.GetAllPositionsAsync(Arg.Any()) .Returns(Task.FromResult>([new Position("CT", Sap, 100, 230m)])); _broker.GetPositionsAsync(Arg.Any()) .Returns(Task.FromResult>([])); var overview = await sut.GetOverviewAsync(); overview.BrokerReachable.Should().BeFalse(); overview.Positions.Should().ContainSingle(); overview.Positions[0].BrokerQuantity.Should().BeNull(); overview.Positions[0].Diverges.Should().BeFalse("eine Abweichung aus fehlenden Daten ist keine"); } [Fact] public async Task Overview_ShowsTheOrigin_AndFindsWhatNoBookClaims() { var sut = CreateSut(out _); _portfolio.GetAllPositionsAsync(Arg.Any()) .Returns(Task.FromResult>( [new Position(BookId.Unassigned.Value, Sap, 100, 230m)])); var alv = InstrumentRef.ForStock("ALV", "EUR"); _broker.GetPositionsAsync(Arg.Any()) .Returns(Task.FromResult>( [ new BrokerPosition { Symbol = "SAP", SecType = "STK", Currency = "EUR", ConId = 1, Quantity = 100, AvgCost = 230m, Instrument = Sap }, new BrokerPosition { Symbol = "ALV", SecType = "STK", Currency = "EUR", ConId = 2, Quantity = 5, AvgCost = 450m, Instrument = alv } ])); var overview = await sut.GetOverviewAsync(); overview.BrokerReachable.Should().BeTrue(); overview.Positions[0].Origin.Should().Be("Herkunft unbekannt"); overview.Positions[0].Diverges.Should().BeFalse(); overview.UnknownToUs.Should().ContainSingle("ALV führt der Broker, wir in keinem Buch") .Which.Symbol.Should().Be("ALV"); } [Fact] public async Task Overview_ReportsADivergenceWhenTheBrokerHoldsSomethingElse() { var sut = CreateSut(out _); _portfolio.GetAllPositionsAsync(Arg.Any()) .Returns(Task.FromResult>([new Position("CT", Sap, 100, 230m)])); _broker.GetPositionsAsync(Arg.Any()) .Returns(Task.FromResult>( [new BrokerPosition { Symbol = "SAP", SecType = "STK", Currency = "EUR", ConId = 1, Quantity = 60, AvgCost = 230m, Instrument = Sap }])); var overview = await sut.GetOverviewAsync(); overview.Positions[0].BrokerQuantity.Should().Be(60m); overview.Positions[0].Diverges.Should().BeTrue(); } [Fact] public async Task Overview_CarriesTheLockThrough() { var sut = CreateSut(out _); _portfolio.GetAllPositionsAsync(Arg.Any()) .Returns(Task.FromResult>([new Position("CT", Sap, 100, 230m)])); _broker.GetPositionsAsync(Arg.Any()) .Returns(Task.FromResult>([])); await sut.SetLockAsync(Ct, Sap, PositionLock.CloseOnly, "nur noch abbauen"); var overview = await sut.GetOverviewAsync(includeBroker: false); overview.Positions[0].Lock.Should().Be(PositionLock.CloseOnly); overview.Positions[0].IsRestricted.Should().BeTrue(); } [Fact] public async Task Overview_DoesNotCallASplitPositionADivergence() { // Am 2026-09-06 auf der Test-VM aufgefallen: 2 Stück SAP in DIAG, 2 in UNASSIGNED, Broker // führt 4 - alles richtig. Zeilenweise verglichen meldete die Ansicht zwei Fehlbestände. // Der Broker kennt unsere Bücher nicht; vergleichbar ist nur die Summe. var sut = CreateSut(out _); _portfolio.GetAllPositionsAsync(Arg.Any()) .Returns(Task.FromResult>( [ new Position("DIAG", Sap, 2, 185m), new Position(BookId.Unassigned.Value, Sap, 2, 187m) ])); _broker.GetPositionsAsync(Arg.Any()) .Returns(Task.FromResult>( [new BrokerPosition { Symbol = "SAP", SecType = "STK", Currency = "EUR", ConId = 1, Quantity = 4, AvgCost = 186m, Instrument = Sap }])); var overview = await sut.GetOverviewAsync(); overview.Positions.Should().HaveCount(2); overview.Positions.Should().OnlyContain(p => !p.Diverges, "2 + 2 sind die 4 des Brokers"); overview.Positions.Should().OnlyContain(p => p.OwnTotalQuantity == 4); overview.Positions.Should().OnlyContain(p => p.IsSharedAcrossBooks); } [Fact] public async Task Overview_StillFindsARealDivergenceAcrossBooks() { // Die Gegenprobe: Summiert wird, aber nicht weggerechnet. Fehlt beim Broker ein Stück, // muss es auffallen - sonst hätte die Zusammenfassung die Prüfung abgeschafft. var sut = CreateSut(out _); _portfolio.GetAllPositionsAsync(Arg.Any()) .Returns(Task.FromResult>( [ new Position("DIAG", Sap, 2, 185m), new Position(BookId.Unassigned.Value, Sap, 2, 187m) ])); _broker.GetPositionsAsync(Arg.Any()) .Returns(Task.FromResult>( [new BrokerPosition { Symbol = "SAP", SecType = "STK", Currency = "EUR", ConId = 1, Quantity = 3, AvgCost = 186m, Instrument = Sap }])); var overview = await sut.GetOverviewAsync(); overview.Positions.Should().OnlyContain(p => p.Diverges, "wir führen 4, der Broker 3"); } }