using FluentAssertions; using IBKRTrader.Core.Reconciliation; using IBKRTrader.Core.Trading; namespace IBKRTrader.Tests.Reconciliation; /// /// 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. /// [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(); 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(), 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(), 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(), 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(), [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(); } }