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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user