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>
This commit is contained in:
Richard
2026-09-06 12:41:06 +02:00
co-authored by Claude Opus 5
parent c87b0195fd
commit 8fd99ee6fa
10 changed files with 1116 additions and 4 deletions
@@ -0,0 +1,251 @@
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();
}
}