Files
IBKRTrader/tests/IBKRTrader.Tests/Reconciliation/PositionReconcilerTests.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

227 lines
9.4 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.Reconciliation;
using IBKRTrader.Core.Trading;
namespace IBKRTrader.Tests.Reconciliation;
/// <summary>
/// Der Bestandsabgleich gegen den Broker (#300/#306). Reine Rechnung – kein Broker, keine
/// Datenbank. Geprüft wird vor allem die Deutung: Zuteilung und Verfall sehen in den Mengen fast
/// gleich aus, führen aber zu völlig verschiedenen Buchungen.
/// </summary>
[Trait("cat", "unit")]
public class PositionReconcilerTests
{
private static readonly DateOnly Today = new(2026, 9, 21);
private static OptionSpec Put(decimal strike = 150m, int daysFromToday = -3) =>
new("AAPL", Today.AddDays(daysFromToday), strike, OptionRight.Put);
private static OptionSpec Call(decimal strike = 200m, int daysFromToday = -3) =>
new("AAPL", Today.AddDays(daysFromToday), strike, OptionRight.Call);
private static Position Own(string book, InstrumentRef instrument, int quantity, decimal price = 1m) =>
new(book, instrument, quantity, price);
private static BrokerPosition Broker(InstrumentRef instrument, int quantity, decimal avgCost = 1m) =>
new()
{
Symbol = instrument.Symbol,
SecType = instrument.Kind == InstrumentKind.Option ? "OPT" : "STK",
Currency = instrument.Currency,
ConId = 1,
Quantity = quantity,
AvgCost = avgCost,
Instrument = instrument
};
// ─── Der Normalfall ──────────────────────────────────────────────────────────
[Fact]
public void MatchingPositions_ProduceNoActions()
{
var aapl = InstrumentRef.ForStock("AAPL");
var own = new[] { Own("OW", aapl, 100, 140m) };
var broker = new[] { Broker(aapl, 100, 140m) };
PositionReconciler.Compare(own, broker, Today).Should().BeEmpty();
}
// ─── Zuteilung ───────────────────────────────────────────────────────────────
[Fact]
public void AssignedShortPut_MovesTheSharesToTheBookThatWroteIt()
{
// Das Buch OW hat einen Put geschrieben. Er wurde zugeteilt: der Kontrakt ist weg, dafür
// liegen 100 Aktien im Konto, die in keiner unserer Zeilen stehen.
var spec = Put(strike: 150m);
var put = InstrumentRef.ForOption(spec);
var aapl = InstrumentRef.ForStock("AAPL");
var own = new[] { Own("OW", put, -1, 2.50m) };
var broker = new[] { Broker(aapl, 100, 150m) };
var actions = PositionReconciler.Compare(own, broker, Today);
actions.Should().HaveCount(2);
var shares = actions.Single(a => a.Instrument.Key == aapl.Key);
shares.Kind.Should().Be(ReconciliationKind.Assignment);
shares.Book.Should().Be("OW", "die Aktien gehören dem Buch, das den Put geschrieben hat");
shares.NewQuantity.Should().Be(100);
shares.AvgPrice.Should().Be(150m, "zugeteilt wird zum Strike, nicht zum Marktpreis");
var contract = actions.Single(a => a.Instrument.Key == put.Key);
contract.NewQuantity.Should().Be(0);
}
[Fact]
public void CalledAwayShortCall_RemovesTheSharesFromTheSameBook()
{
// Umgekehrter Fall: Der geschriebene Call wurde abgerufen, die Aktien sind weg.
var spec = Call(strike: 200m);
var call = InstrumentRef.ForOption(spec);
var aapl = InstrumentRef.ForStock("AAPL");
var own = new[]
{
Own("OW", call, -1, 3m),
Own("OW", aapl, 100, 140m)
};
var broker = Array.Empty<BrokerPosition>();
var actions = PositionReconciler.Compare(own, broker, Today);
var shares = actions.Single(a => a.Instrument.Key == aapl.Key);
shares.Kind.Should().Be(ReconciliationKind.Assignment);
shares.Book.Should().Be("OW");
shares.NewQuantity.Should().Be(0, "100 Aktien geliefert, keine mehr übrig");
actions.Single(a => a.Instrument.Key == call.Key).NewQuantity.Should().Be(0);
}
[Fact]
public void PartialAssignment_LeavesTheRestAsAnOrdinaryDifference()
{
// Zwei Puts geschrieben, aber nur 100 Aktien im Konto: einer wurde zugeteilt, für den
// zweiten fehlt die Deckung der Erklärung. Der Rest darf nicht als Zuteilung durchgehen.
var spec = Put(strike: 150m);
var put = InstrumentRef.ForOption(spec);
var aapl = InstrumentRef.ForStock("AAPL");
var own = new[] { Own("OW", put, -2, 2.50m) };
var broker = new[] { Broker(aapl, 100, 150m) };
var actions = PositionReconciler.Compare(own, broker, Today);
// Erwartet würden 200 Aktien; da nur 100 da sind, greift die Zuteilungsdeutung nicht.
actions.Should().NotContain(a => a.Kind == ReconciliationKind.Assignment);
actions.Should().Contain(a => a.Instrument.Key == aapl.Key
&& a.Kind == ReconciliationKind.Foreign);
}
// ─── Verfall ─────────────────────────────────────────────────────────────────
[Fact]
public void ExpiredContract_IsClosedWithoutTouchingShares()
{
var put = InstrumentRef.ForOption(Put(daysFromToday: -1));
var own = new[] { Own("OW", put, -1, 2.50m) };
var actions = PositionReconciler.Compare(own, Array.Empty<BrokerPosition>(), Today);
var action = actions.Should().ContainSingle().Subject;
action.Kind.Should().Be(ReconciliationKind.Expiry);
action.NewQuantity.Should().Be(0);
action.Explanation.Should().Contain("wertlos verfallen");
}
[Fact]
public void ContractGoneBeforeExpiry_IsReportedAsDifference_NotAsExpiry()
{
// Verfall erst in zehn Tagen, der Kontrakt ist trotzdem weg: von Hand geschlossen.
// Das darf nicht als Verfall durchgehen – es ist eine Abweichung, die jemand sehen soll.
var put = InstrumentRef.ForOption(Put(daysFromToday: +10));
var own = new[] { Own("OW", put, -1, 2.50m) };
var actions = PositionReconciler.Compare(own, Array.Empty<BrokerPosition>(), Today);
var action = actions.Should().ContainSingle().Subject;
action.Kind.Should().Be(ReconciliationKind.Shortfall);
action.Explanation.Should().Contain("Von Hand geschlossen?");
}
// ─── Fremde und abweichende Bestände ────────────────────────────────────────
[Fact]
public void PositionOnlyAtTheBroker_GoesToUnassigned()
{
var msft = InstrumentRef.ForStock("MSFT");
var broker = new[] { Broker(msft, 50, 400m) };
var action = PositionReconciler.Compare(Array.Empty<Position>(), broker, Today)
.Should().ContainSingle().Subject;
action.Kind.Should().Be(ReconciliationKind.Foreign);
action.Book.Should().Be("UNASSIGNED");
action.NewQuantity.Should().Be(50);
}
[Fact]
public void MoreAtTheBrokerThanBooked_IsBookedAsExcess_WithoutTouchingTheModule()
{
var aapl = InstrumentRef.ForStock("AAPL");
var own = new[] { Own("OW", aapl, 100, 140m) };
var broker = new[] { Broker(aapl, 130, 140m) };
var action = PositionReconciler.Compare(own, broker, Today).Should().ContainSingle().Subject;
action.Kind.Should().Be(ReconciliationKind.Excess);
action.Book.Should().Be("UNASSIGNED", "wer den Überschuss aufgebaut hat, weiß der Broker nicht");
action.NewQuantity.Should().Be(30);
}
[Fact]
public void LessAtTheBrokerThanBooked_IsBookedAsShortfall()
{
var aapl = InstrumentRef.ForStock("AAPL");
var own = new[] { Own("OW", aapl, 100, 140m) };
var broker = new[] { Broker(aapl, 60, 140m) };
var action = PositionReconciler.Compare(own, broker, Today).Should().ContainSingle().Subject;
action.Kind.Should().Be(ReconciliationKind.Shortfall);
action.NewQuantity.Should().Be(-40, "der Fehlbestand wird bei UNASSIGNED gegengebucht");
}
[Fact]
public void ContractsTheModelCannotRead_AreIgnored()
{
// Ein Future im Konto: unser Modell kennt ihn nicht, der Adapter liefert kein Instrument.
// Er darf nicht als Aktie durchgehen.
var future = new BrokerPosition
{
Symbol = "ES", SecType = "FUT", Currency = "USD", ConId = 7,
Quantity = 1, AvgCost = 5_000m, Instrument = null
};
PositionReconciler.Compare(Array.Empty<Position>(), [future], Today).Should().BeEmpty();
}
[Fact]
public void CurrenciesAreSeparateInstruments()
{
// Dieselbe Aktie in EUR und USD: zwei Instrumente, zwei Bestände. Würden sie
// zusammengeworfen, sähe der Abgleich eine Abweichung, die es nicht gibt.
var sapEur = InstrumentRef.ForStock("SAP", "EUR");
var sapUsd = InstrumentRef.ForStock("SAP", "USD");
var own = new[] { Own("OW", sapEur, 10, 100m), Own("OW", sapUsd, 5, 120m) };
var broker = new[] { Broker(sapEur, 10, 100m), Broker(sapUsd, 5, 120m) };
PositionReconciler.Compare(own, broker, Today).Should().BeEmpty();
}
}