Files
IBKRTrader/tests/IBKRTrader.Tests/Reconciliation/PositionReconcileWorkerTests.cs
T
RichardandClaude Opus 5 40f7bb0d6e Bestandsabgleich gegen den Broker: Zuteilung, Verfall, Fremdbestand
Zuteilung und Verfall aendern Positionen ohne eine Order von uns - es gibt kein
Ereignis, an das sich ein Abgleich haengen koennte. Nach einem Verfallstag
fuehren wir Kontrakte, die es nicht mehr gibt; nach einer Zuteilung liegen
Aktien im Konto, die in keinem Buch stehen. Beides verschiebt still die
Deckungslage: Ein Covered Call, dessen Aktien in Wahrheit abgerufen wurden, ist
ab dann ein nackter Call.

PositionReconciler ist reine Rechnung und deutet die Differenz, statt nur Mengen
anzugleichen. Verschwindet ein geschriebener Put und tauchen zugleich Aktien des
Basiswerts auf, gehoeren diese Aktien dem Buch, das den Put geschrieben hat -
landeten sie in UNASSIGNED, verloere ein Wheel-Modul genau den Bestand, aus dem
es den naechsten Call schreiben muss. Der Einstand ist dabei der Strike, denn zu
genau dem Preis wurde geliefert. Passt die Aktienbewegung nicht zur Groesse der
Option, gilt sie NICHT als Zuteilung.

Verfall wird nur angenommen, wenn der Verfallstag erreicht ist. Ein Kontrakt,
der vorher verschwindet, ist eine Abweichung und wird als solche gemeldet - von
Hand geschlossen ist kein Verfall.

Die gefaehrlichste Stelle ist die leere Antwort: Die Leseschnittstelle liefert
bei gestoerter Verbindung eine leere Liste, die von einem leeren Konto nicht zu
unterscheiden ist. Solange wir selbst Positionen fuehren, gilt sie deshalb als
Stoerung. Ohne diese Sperre setzte ein Verbindungsabbruch jede Position auf null.

Gehandelt wird nie. Der Abgleich bucht ausschliesslich nach.

BrokerPosition traegt jetzt das Instrument (IbkrMapping.ToInstrument); ein
Kontrakt, den unser Modell nicht kennt - Future, Devise, Fonds -, liefert null
und wird uebersprungen statt als Aktie gedeutet.

Gemessen auf dem Testserver gegen das Paper-Konto: vier Altlasten erkannt
(GOOG, MIGA:EUR, die SAP-ADR aus dem Fehllauf und zwei ungebuchte SAP:EUR),
alle an UNASSIGNED gebucht, das Buch DIAG unangetastet. Gegenprobe danach:
"Bestand stimmt ueberein (5 Positionen)". 402 Tests gruen.

Erledigt den offenen Teil von #300, Kern von #306.

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

108 lines
4.3 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.Logging;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Reconciliation;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers.BuiltIn;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace IBKRTrader.Tests.Reconciliation;
/// <summary>
/// Der Worker um den Abgleich. Die Deutung selbst ist in <see cref="PositionReconcilerTests"/>
/// geprüft – hier geht es um das Verhalten drumherum, vor allem um den Fall, in dem der Broker
/// nichts meldet.
/// </summary>
[Trait("cat", "unit")]
public class PositionReconcileWorkerTests
{
private sealed class InMemoryFactory(DbContextOptions<CoreDbContext> o) : IDbContextFactory<CoreDbContext>
{
public CoreDbContext CreateDbContext() => new(o);
}
/// <summary>Der Worker ohne DB-Protokollierung; ExecuteAsync wird direkt aufgerufen.</summary>
private sealed class TestableWorker : PositionReconcileWorker
{
public TestableWorker(IPortfolioService portfolio, IBrokerPortfolioReader reader)
: base(new LoggingService(), Dbf(),
new ReconciliationService(portfolio, reader, new LoggingService())) { }
private static IDbContextFactory<CoreDbContext> Dbf() =>
new InMemoryFactory(new DbContextOptionsBuilder<CoreDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
public Task RunOnceAsync(CancellationToken ct = default) => ExecuteAsync(ct);
}
private static readonly InstrumentRef Aapl = InstrumentRef.ForStock("AAPL");
[Fact]
public async Task EmptyBrokerAnswer_DoesNotWipeTheBooks()
{
// Der gefährlichste Fall: Die Leseschnittstelle liefert bei gestörter Verbindung eine
// leere Liste. Würde der Worker sie für bare Münze nehmen, setzte ein Verbindungsabbruch
// jede Position auf null - und mit ihr die gesamte Deckungslage.
var portfolio = Substitute.For<IPortfolioService>();
portfolio.GetAllPositionsAsync(Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<Position>>(_ => [new Position("OW", Aapl, 100, 140m)]);
var reader = Substitute.For<IBrokerPortfolioReader>();
reader.GetPositionsAsync(Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<BrokerPosition>>(_ => []);
await new TestableWorker(portfolio, reader).RunOnceAsync();
await portfolio.DidNotReceiveWithAnyArgs().ApplyAdjustmentAsync(
default!, default, default, default, default!, default);
}
[Fact]
public async Task EmptyOnBothSides_IsSimplyNothingToDo()
{
var portfolio = Substitute.For<IPortfolioService>();
portfolio.GetAllPositionsAsync(Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<Position>>(_ => []);
var reader = Substitute.For<IBrokerPortfolioReader>();
reader.GetPositionsAsync(Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<BrokerPosition>>(_ => []);
await new TestableWorker(portfolio, reader).RunOnceAsync();
await portfolio.DidNotReceiveWithAnyArgs().ApplyAdjustmentAsync(
default!, default, default, default, default!, default);
}
[Fact]
public async Task ForeignPosition_IsBookedToUnassigned()
{
var portfolio = Substitute.For<IPortfolioService>();
portfolio.GetAllPositionsAsync(Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<Position>>(_ => []);
var msft = InstrumentRef.ForStock("MSFT");
var reader = Substitute.For<IBrokerPortfolioReader>();
reader.GetPositionsAsync(Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<BrokerPosition>>(_ =>
[
new BrokerPosition
{
Symbol = "MSFT", SecType = "STK", Currency = "USD", ConId = 1,
Quantity = 50, AvgCost = 400m, Instrument = msft
}
]);
await new TestableWorker(portfolio, reader).RunOnceAsync();
await portfolio.Received(1).ApplyAdjustmentAsync(
"UNASSIGNED",
Arg.Is<InstrumentRef>(i => i.Key == msft.Key),
50,
400m,
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
}