Files
IBKRTrader/tests/IBKRTrader.Tests/Positions/PositionManagerTests.cs
T
RichardandClaude Opus 5 834f035ff6 Abweichung wird je Instrument bestimmt, Sperren auch kopflos setzbar
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>
2026-09-06 12:45:46 +02:00

304 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}
}