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();
}
}