Files
IBKRTrader/tests/IBKRTrader.Tests/Positions/PositionManagerTests.cs
T
RichardandClaude Opus 5 8fd99ee6fa Positionsverwaltung: Herkunft sichtbar, Zuordnung aenderbar, Sperren einstellbar
Erster Teil von #485. Der PositionManager im Core beantwortet drei Fragen, die
sich beim Debuggen sofort stellen: Wem gehoert eine Position, was sagt der Broker
dazu, und was ist gesperrt.

Drei Zugaenge auf denselben Dienst - das Fenster der Shell, --positions und
--reassign im kopflosen Betrieb, GET /api/positions. Der Testbetrieb laeuft als
systemd-Dienst; ein Werkzeug, das nur in der Oberflaeche existiert, fehlt genau
dann, wenn man es braucht.

Entscheidungen, die im Code begruendet stehen:

- BrokerQuantity ist nullable und heisst "nicht bekannt", nicht "null Stueck".
  Ohne Verbindung sieht ein leergeraeumtes Konto genauso aus wie eine Stoerung;
  aus dem einen darf nicht auf das andere geschlossen werden.
- Umbuchen aendert die Zuordnung, nie die Menge. Eine Umbuchung, die eine
  Stueckzahl bewegt, waere ein Handel ohne Order und stuende in keiner Historie.
- Gegen eine entgegengesetzte Position wird abgelehnt: Das waere eine
  Glattstellung und gehoert ueber eine Order gemacht, mit Preis und Historie.
- Das Quellbuch ist Pflicht - Schutz gegen die falsche Zeile -, ebenso eine
  Begruendung, sonst ist der Eingriff spaeter nicht mehr erklaerbar.
- Das Cap des Zielbuchs sperrt NICHT. Die Position existiert bereits; sie zu
  benennen erzeugt kein neues Risiko. Wuerde die Grenze das verhindern, bliebe
  jede zu grosse Position fuer immer in UNASSIGNED.
- Der REST-Endpunkt ist lesend. Der Listener kennt keine Anmeldung; ein
  schreibender Zugriff waere ein Eingriff in die Buecher, den niemand belegen kann.

Beim ersten Testlauf gefunden: (Module, ContractKey) ist der Primaerschluessel
von core_position, das Buch laesst sich in der Zeile nicht ueberschreiben.
Umgebucht wird deshalb durch Loeschen und Neuanlegen.

471 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:41:06 +02:00

252 lines
10 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();
}
}