Der erste Lauf gegen die Testmaschine zeigte den Fehler sofort: 2 Stueck SAP in DIAG, 2 in UNASSIGNED, der Broker fuehrt 4 - alles richtig, aber zeilenweise verglichen meldete die Ansicht zwei Fehlbestaende. Der Broker kennt unsere Buecher nicht; vergleichbar ist nur die Summe ueber alle. ManagedPosition traegt jetzt OwnTotalQuantity, und Diverges misst daran. Zwei Tests halten beide Richtungen fest: eine aufgeteilte Position ist keine Abweichung, eine echte verschwindet nicht in der Summe. Dazu --lock und --instrument-rule fuer den kopflosen Betrieb. Beide mit Trockenlauf; die Stufe "Locked" schreibt vor dem Setzen aus, dass sie auch das SCHLIESSEN durch Module verhindert - das gehoert dorthin, wo entschieden wird, nicht in den Stoerungsfall. Auf der Testmaschine durchgespielt: GOOG von UNASSIGNED nach MANUAL umgebucht (Menge und Einstand unveraendert), dann auf CloseOnly gesetzt - beides steht in der Uebersicht. 473 Tests gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
304 lines
13 KiB
C#
304 lines
13 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Die Positionsverwaltung (#485). Geprüft wird gegen die EF-InMemory-Datenbank; Broker und
|
||
/// Buchführung sind gemockt.
|
||
///
|
||
/// <para>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.</para>
|
||
/// </summary>
|
||
[Trait("cat", "unit")]
|
||
public class PositionManagerTests
|
||
{
|
||
private sealed class InMemoryFactory(DbContextOptions<CoreDbContext> options)
|
||
: IDbContextFactory<CoreDbContext>
|
||
{
|
||
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<IPortfolioService>();
|
||
private readonly IBrokerPortfolioReader _broker = Substitute.For<IBrokerPortfolioReader>();
|
||
|
||
private PositionManager CreateSut(out IDbContextFactory<CoreDbContext> factory)
|
||
{
|
||
var options = new DbContextOptionsBuilder<CoreDbContext>()
|
||
.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<CoreDbContext> 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<CancellationToken>())
|
||
.Returns(Task.FromResult<IReadOnlyList<Position>>([new Position("CT", Sap, 100, 230m)]));
|
||
_broker.GetPositionsAsync(Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult<IReadOnlyList<BrokerPosition>>([]));
|
||
|
||
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<CancellationToken>())
|
||
.Returns(Task.FromResult<IReadOnlyList<Position>>(
|
||
[new Position(BookId.Unassigned.Value, Sap, 100, 230m)]));
|
||
|
||
var alv = InstrumentRef.ForStock("ALV", "EUR");
|
||
_broker.GetPositionsAsync(Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult<IReadOnlyList<BrokerPosition>>(
|
||
[
|
||
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<CancellationToken>())
|
||
.Returns(Task.FromResult<IReadOnlyList<Position>>([new Position("CT", Sap, 100, 230m)]));
|
||
_broker.GetPositionsAsync(Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult<IReadOnlyList<BrokerPosition>>(
|
||
[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<CancellationToken>())
|
||
.Returns(Task.FromResult<IReadOnlyList<Position>>([new Position("CT", Sap, 100, 230m)]));
|
||
_broker.GetPositionsAsync(Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult<IReadOnlyList<BrokerPosition>>([]));
|
||
|
||
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<CancellationToken>())
|
||
.Returns(Task.FromResult<IReadOnlyList<Position>>(
|
||
[
|
||
new Position("DIAG", Sap, 2, 185m),
|
||
new Position(BookId.Unassigned.Value, Sap, 2, 187m)
|
||
]));
|
||
|
||
_broker.GetPositionsAsync(Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult<IReadOnlyList<BrokerPosition>>(
|
||
[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<CancellationToken>())
|
||
.Returns(Task.FromResult<IReadOnlyList<Position>>(
|
||
[
|
||
new Position("DIAG", Sap, 2, 185m),
|
||
new Position(BookId.Unassigned.Value, Sap, 2, 187m)
|
||
]));
|
||
|
||
_broker.GetPositionsAsync(Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult<IReadOnlyList<BrokerPosition>>(
|
||
[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");
|
||
}
|
||
}
|