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");
}
}