From 40f7bb0d6e3359442c301016609fcc05af0bcbe8 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 5 Sep 2026 12:40:50 +0200 Subject: [PATCH] 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 --- .../Reconciliation/PositionReconciler.cs | 236 ++++++++++++++++++ .../Reconciliation/ReconciliationService.cs | 92 +++++++ .../Trading/IPortfolioService.cs | 8 + .../Trading/Ibkr/IbkrConnection.cs | 3 +- .../Trading/Ibkr/IbkrMapping.cs | 50 ++++ .../Trading/PortfolioService.cs | 58 +++++ src/IBKRTrader.Core/Trading/TradingModels.cs | 10 + .../BuiltIn/PositionReconcileWorker.cs | 56 +++++ src/IBKRTrader.Daemon/Program.cs | 35 ++- src/IBKRTrader.Hosting/AppHostBuilder.cs | 5 + .../PositionReconcileWorkerTests.cs | 107 ++++++++ .../Reconciliation/PositionReconcilerTests.cs | 226 +++++++++++++++++ 12 files changed, 883 insertions(+), 3 deletions(-) create mode 100644 src/IBKRTrader.Core/Reconciliation/PositionReconciler.cs create mode 100644 src/IBKRTrader.Core/Reconciliation/ReconciliationService.cs create mode 100644 src/IBKRTrader.Core/Workers/BuiltIn/PositionReconcileWorker.cs create mode 100644 tests/IBKRTrader.Tests/Reconciliation/PositionReconcileWorkerTests.cs create mode 100644 tests/IBKRTrader.Tests/Reconciliation/PositionReconcilerTests.cs diff --git a/src/IBKRTrader.Core/Reconciliation/PositionReconciler.cs b/src/IBKRTrader.Core/Reconciliation/PositionReconciler.cs new file mode 100644 index 0000000..75d94ac --- /dev/null +++ b/src/IBKRTrader.Core/Reconciliation/PositionReconciler.cs @@ -0,0 +1,236 @@ +using IBKRTrader.Core.Capital; +using IBKRTrader.Core.Trading; + +namespace IBKRTrader.Core.Reconciliation; + +/// Was der Abgleich an einer Position festgestellt hat. +public enum ReconciliationKind +{ + /// Ein geschriebener Kontrakt wurde zugeteilt bzw. ein gekaufter ausgeübt. + Assignment, + + /// Ein Kontrakt ist wertlos verfallen. + Expiry, + + /// Der Broker führt mehr, als wir gebucht haben – Herkunft unbekannt. + Excess, + + /// Wir führen mehr, als der Broker hat. Unsere Buchung ist zu hoch. + Shortfall, + + /// Ein Instrument, das wir überhaupt nicht führen. + Foreign +} + +/// +/// Eine festgestellte Abweichung samt der Buchung, die sie ausgleicht. +/// +/// Buch, dessen Bestand zu ändern ist. +/// Betroffenes Instrument. +/// Menge, die danach im Buch steht – vorzeichenbehaftet, 0 = geschlossen. +/// Einstand, der dabei anzusetzen ist. +/// Begründung, so wie sie ins Log gehört. +public sealed record ReconciliationAction( + ReconciliationKind Kind, + string Book, + InstrumentRef Instrument, + int NewQuantity, + decimal AvgPrice, + string Explanation); + +/// +/// Vergleicht die eigene Buchführung mit dem Bestand des Brokers und leitet daraus die nötigen +/// Korrekturen ab. Reine Rechnung – kein Broker, keine Datenbank, kein Zeitgeber. +/// +/// Warum es das braucht. Zuteilung und Verfall ändern Positionen ohne eine Order von +/// uns. Nach einer Zuteilung besitzt das Konto 100 Aktien, die in keiner unserer Zeilen stehen; +/// nach einem Verfall führen wir einen Kontrakt, den es nicht mehr gibt. Beides verschiebt still +/// die Deckungslage: Ein Covered Call, dessen Aktien in Wahrheit fehlen, ist ein nackter Call. +/// +/// Der Broker ist die Wahrheit. Bei jeder Abweichung gewinnt sein Bestand. Wir +/// buchen nach – gehandelt wird hier nichts, unter keinen Umständen. +/// +/// Zuteilungen werden dem richtigen Buch zugeordnet. Das ist der Grund, warum dieser +/// Vergleich mehr ist als „Mengen angleichen": Verschwindet ein geschriebener Put und tauchen +/// zugleich Aktien des Basiswerts auf, gehören diese Aktien dem Buch, das den Put geschrieben hat. +/// Landeten sie stattdessen in UNASSIGNED, verlöre ein Wheel-Modul genau den Bestand, aus dem es +/// den nächsten Call schreiben muss. +/// +public static class PositionReconciler +{ + /// + /// Vergleicht und liefert die nötigen Buchungen. Eine leere Liste heißt: alles stimmt überein. + /// + /// Eigene Positionen, je Buch und Instrument. + /// Bestand laut Broker – ohne Bücher, er kennt keine. + /// Heutiger Tag in der Betriebszeitzone; entscheidet über „verfallen". + /// Buch für Bestände ohne erkennbare Herkunft. + public static IReadOnlyList Compare( + IReadOnlyList own, + IReadOnlyList broker, + DateOnly today, + string? unassignedBook = null) + { + var unassigned = unassignedBook ?? BookId.Unassigned.Value; + var actions = new List(); + + // Der Broker führt je Instrument eine Menge; unsere Zeilen können dasselbe Instrument in + // mehreren Büchern halten. + var brokerByKey = broker + .Where(p => p.Instrument is not null && p.Quantity != 0) + .GroupBy(p => p.Instrument!.Value.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => new BrokerLot( + g.First().Instrument!.Value, + (int)g.Sum(p => p.Quantity), + g.First().AvgCost), StringComparer.Ordinal); + + var ownByKey = own + .Where(p => p.Quantity != 0) + .GroupBy(p => p.Instrument.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); + + // Was an Aktienmenge je Basiswert noch unerklärt ist. Zuteilungen zehren davon; was übrig + // bleibt, ist ein echter Über- oder Fehlbestand. + var openDelta = new Dictionary(StringComparer.Ordinal); + foreach (var key in ownByKey.Keys.Union(brokerByKey.Keys, StringComparer.Ordinal)) + { + var ownQty = ownByKey.TryGetValue(key, out var rows) ? rows.Sum(r => r.Quantity) : 0; + var brokerQty = brokerByKey.TryGetValue(key, out var lot) ? lot.Quantity : 0; + if (ownQty != brokerQty) openDelta[key] = brokerQty - ownQty; + } + + // ── 1. Verschwundene Kontrakte deuten: Zuteilung oder Verfall ──────────── + foreach (var (key, rows) in ownByKey) + { + if (brokerByKey.ContainsKey(key)) continue; // steht noch, kein Sonderfall + var instrument = rows[0].Instrument; + if (instrument.Option is not { } option) continue; // Aktien: unten als Fehlbestand + + foreach (var row in rows) + { + var contracts = Math.Abs(row.Quantity); + var expected = ExpectedShareChange(row.Quantity, option); + var underlying = instrument.UnderlyingRef; + + if (expected != 0 && Consume(openDelta, underlying.Key, expected)) + { + // Die Aktienbewegung passt zur verschwundenen Option: zugeteilt bzw. ausgeübt. + // Der Einstand ist der Strike – zu genau diesem Preis wurde geliefert. + var ownShares = ownByKey.TryGetValue(underlying.Key, out var shareRows) + ? shareRows.Where(r => r.Module == row.Module).Sum(r => r.Quantity) + : 0; + + actions.Add(new ReconciliationAction( + ReconciliationKind.Assignment, row.Module, underlying, + ownShares + expected, option.Strike, + $"{contracts} {option.RightCode}-Kontrakt(e) {instrument.Key} " + + $"{(row.Quantity < 0 ? "zugeteilt" : "ausgeübt")}: " + + $"{expected:+#;-#;0} Aktien {underlying.Key} zu {option.Strike} " + + $"gehen an {row.Module}.")); + + actions.Add(new ReconciliationAction( + ReconciliationKind.Assignment, row.Module, instrument, 0, row.AvgPrice, + $"Kontrakt {instrument.Key} ist durch Zuteilung erloschen.")); + } + else if (option.Expiry <= today) + { + actions.Add(new ReconciliationAction( + ReconciliationKind.Expiry, row.Module, instrument, 0, row.AvgPrice, + $"{contracts} Kontrakt(e) {instrument.Key} sind am {option.Expiry:yyyy-MM-dd} " + + "wertlos verfallen.")); + } + else + { + // Weder Zuteilung noch Verfall: der Kontrakt ist vor dem Verfallstag weg. + // Das ist meldenswert – etwa ein Schließen von Hand in TWS. + actions.Add(new ReconciliationAction( + ReconciliationKind.Shortfall, row.Module, instrument, 0, row.AvgPrice, + $"{contracts} Kontrakt(e) {instrument.Key} führt der Broker nicht mehr, " + + $"obwohl der Verfall erst am {option.Expiry:yyyy-MM-dd} ist. " + + "Von Hand geschlossen?")); + } + + openDelta.Remove(key); + } + } + + // ── 2. Was danach an Abweichung bleibt ─────────────────────────────────── + foreach (var (key, delta) in openDelta.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + { + if (delta == 0) continue; + + var hasOwn = ownByKey.TryGetValue(key, out var rows) && rows.Count > 0; + var lot = brokerByKey.TryGetValue(key, out var l) ? l : null; + + if (!hasOwn) + { + // Nur beim Broker: ein Bestand, den niemand von uns aufgebaut hat. + actions.Add(new ReconciliationAction( + ReconciliationKind.Foreign, unassigned, lot!.Instrument, lot.Quantity, lot.AvgCost, + $"{lot.Quantity} {lot.Instrument.Key} beim Broker, bei uns nicht gebucht – " + + $"wird {unassigned} zugeschrieben.")); + continue; + } + + // Wir führen das Instrument bereits. Die Differenz kann nicht sinnvoll auf mehrere + // Bücher verteilt werden – wer sie verursacht hat, weiß nur der Broker, und der kennt + // unsere Bücher nicht. Sie geht deshalb ungeteilt an UNASSIGNED bzw. wird dort + // abgebaut; die Bücher der Module bleiben unangetastet. + var instrument = rows![0].Instrument; + var ownInUnassigned = rows.Where(r => r.Module == unassigned).Sum(r => r.Quantity); + + actions.Add(new ReconciliationAction( + delta > 0 ? ReconciliationKind.Excess : ReconciliationKind.Shortfall, + unassigned, instrument, ownInUnassigned + delta, + lot?.AvgCost ?? rows[0].AvgPrice, + delta > 0 + ? $"Broker führt {delta} mehr {instrument.Key} als wir – Überschuss geht an {unassigned}." + : $"Broker führt {-delta} weniger {instrument.Key} als wir – Fehlbestand wird " + + $"bei {unassigned} abgezogen.")); + } + + return actions; + } + + /// + /// Wie sich der Aktienbestand ändert, wenn dieser Kontrakt zugeteilt bzw. ausgeübt wird. + /// + /// + /// Geschriebener Put: wir müssen kaufen → Bestand steigt. + /// Geschriebener Call: wir müssen liefern → Bestand fällt. + /// Gekaufter Call: Bezug → Bestand steigt. Gekaufter Put: Andienung → Bestand fällt. + /// + /// + private static int ExpectedShareChange(int contracts, OptionSpec option) + { + var shares = Math.Abs(contracts) * option.Multiplier; + + return (option.Right, IsShort: contracts < 0) switch + { + (OptionRight.Put, true) => shares, // zugeteilt bekommen + (OptionRight.Call, true) => -shares, // abgerufen worden + (OptionRight.Call, false) => shares, // selbst bezogen + (OptionRight.Put, false) => -shares, // selbst angedient + _ => 0 + }; + } + + /// + /// Verrechnet eine erwartete Aktienbewegung mit dem noch unerklärten Delta des Basiswerts. + /// Nur wenn das Delta in dieselbe Richtung zeigt und groß genug ist, gilt die Bewegung als + /// erklärt – sonst wäre jede zufällige Änderung am Basiswert eine „Zuteilung". + /// + private static bool Consume(Dictionary openDelta, string underlyingKey, int expected) + { + if (!openDelta.TryGetValue(underlyingKey, out var delta)) return false; + if (Math.Sign(delta) != Math.Sign(expected)) return false; + if (Math.Abs(delta) < Math.Abs(expected)) return false; + + var rest = delta - expected; + if (rest == 0) openDelta.Remove(underlyingKey); + else openDelta[underlyingKey] = rest; + return true; + } + + private sealed record BrokerLot(InstrumentRef Instrument, int Quantity, decimal AvgCost); +} diff --git a/src/IBKRTrader.Core/Reconciliation/ReconciliationService.cs b/src/IBKRTrader.Core/Reconciliation/ReconciliationService.cs new file mode 100644 index 0000000..30f79ad --- /dev/null +++ b/src/IBKRTrader.Core/Reconciliation/ReconciliationService.cs @@ -0,0 +1,92 @@ +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Time; +using IBKRTrader.Core.Trading; + +namespace IBKRTrader.Core.Reconciliation; + +/// Ergebnis eines Abgleichs. +/// +/// Der Abgleich wurde ausgesetzt – der Broker meldete nichts, während wir Positionen führen. +/// +public sealed record ReconciliationResult( + IReadOnlyList Actions, + int OwnPositions, + int BrokerPositions, + bool Applied, + bool Skipped = false) +{ + public int Assignments => Actions.Count(a => a.Kind == ReconciliationKind.Assignment); + public int Expiries => Actions.Count(a => a.Kind == ReconciliationKind.Expiry); + public int Differences => Actions.Count - Assignments - Expiries; + + public string Summary => Skipped + ? "Ausgesetzt – der Broker meldete keine Positionen." + : Actions.Count == 0 + ? $"Bestand stimmt überein ({OwnPositions} Position(en))." + : $"{Actions.Count} Korrektur(en): {Assignments} aus Zuteilung/Ausübung, " + + $"{Expiries} durch Verfall, {Differences} sonstige Abweichung(en)."; +} + +/// +/// Führt den Bestandsabgleich aus: Zahlen holen, entscheiden +/// lassen, Ergebnis buchen. +/// +/// Bewusst getrennt vom Worker: derselbe Ablauf wird zweimal gebraucht – periodisch im +/// Betrieb und einmalig über die Kommandozeile, dort auch als Trockenlauf. Ein Abgleich, den man +/// nicht erst ansehen kann, bevor er bucht, wäre im Störungsfall unbrauchbar. +/// +public sealed class ReconciliationService +{ + private const string LogModule = "Core"; + + private readonly IPortfolioService _portfolio; + private readonly IBrokerPortfolioReader _reader; + private readonly LoggingService _logger; + + public ReconciliationService( + IPortfolioService portfolio, IBrokerPortfolioReader reader, LoggingService logger) + { + _portfolio = portfolio; + _reader = reader; + _logger = logger; + } + + /// + /// true bucht die Korrekturen, false stellt sie nur fest. Gehandelt wird in + /// keinem der beiden Fälle. + /// + public async Task RunAsync(bool apply, CancellationToken ct = default) + { + var own = await _portfolio.GetAllPositionsAsync(ct).ConfigureAwait(false); + var broker = await _reader.GetPositionsAsync(ct).ConfigureAwait(false); + + // Die Leseschnittstelle liefert bei gestörter Verbindung eine leere Liste – von einem + // tatsächlich leeren Konto ist sie nicht zu unterscheiden. Diesen Fall nicht zu behandeln + // wäre der gefährlichste Fehler des ganzen Abgleichs: ein Verbindungsabbruch setzte jede + // Position auf null und löste damit die gesamte Deckungslage auf. Solange wir selbst + // etwas führen, gilt eine leere Antwort deshalb als Störung, nicht als Wahrheit. + if (broker.Count == 0 && own.Count > 0) + { + _logger.Warn(LogModule, + $"Broker meldet keine Positionen, wir führen {own.Count}. Abgleich ausgesetzt – " + + "eine leere Antwort ist von einer gestörten Verbindung nicht zu unterscheiden."); + return new ReconciliationResult([], own.Count, 0, Applied: false, Skipped: true); + } + + var actions = PositionReconciler.Compare( + own, broker, DateOnly.FromDateTime(AppTimeZone.Now)); + + if (apply) + { + foreach (var action in actions) + { + ct.ThrowIfCancellationRequested(); + await _portfolio.ApplyAdjustmentAsync( + action.Book, action.Instrument, action.NewQuantity, action.AvgPrice, + action.Explanation, ct).ConfigureAwait(false); + } + } + + return new ReconciliationResult(actions, own.Count, broker.Count, apply); + } +} diff --git a/src/IBKRTrader.Core/Trading/IPortfolioService.cs b/src/IBKRTrader.Core/Trading/IPortfolioService.cs index 0db9ace..b48431a 100644 --- a/src/IBKRTrader.Core/Trading/IPortfolioService.cs +++ b/src/IBKRTrader.Core/Trading/IPortfolioService.cs @@ -24,6 +24,14 @@ public interface IPortfolioService /// Task GetPositionQuantityAsync(string module, InstrumentRef instrument, CancellationToken ct = default); + /// + /// Setzt den Bestand auf einen beim Broker festgestellten Wert – für den Abgleich. Schreibt + /// keinen Eintrag in die Handelshistorie: es hat kein Handel von uns stattgefunden. + /// + Task ApplyAdjustmentAsync( + string module, InstrumentRef instrument, int newQuantity, decimal avgPrice, + string reason, CancellationToken ct = default); + /// Verbucht einen Fill: aktualisiert Position, Budget und Trade-Historie. Task RecordFillAsync( string module, InstrumentRef instrument, TradeSide side, diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs index 216118a..0ce2818 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs @@ -617,7 +617,8 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable AvgCost = (decimal)averageCost, MarketPrice = (decimal)marketPrice, MarketValue = (decimal)marketValue, - UnrealizedPnl = (decimal)unrealizedPNL + UnrealizedPnl = (decimal)unrealizedPNL, + Instrument = IbkrMapping.ToInstrument(contract) }); } diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs index 5601647..9589595 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs @@ -60,6 +60,56 @@ internal static class IbkrMapping TradingClass = spec.TradingClass ?? "" }; + /// + /// Der Rückweg: aus einem Kontrakt des Brokers das Instrument, wie wir es führen. + /// null bei allem, was unser Modell nicht kennt – Futures, Devisen, Fonds. + /// + /// Gebraucht für den Bestandsabgleich. Dort ist ein null die ehrliche Antwort: + /// Wer einen unbekannten Kontrakt zur Aktie erklärt, bucht einen Bestand, den es so nicht + /// gibt, und verschiebt damit genau die Deckungslage, die der Abgleich sichern soll. + /// + public static InstrumentRef? ToInstrument(Contract? contract) + { + if (contract is null || string.IsNullOrWhiteSpace(contract.Symbol)) return null; + + var currency = string.IsNullOrWhiteSpace(contract.Currency) + ? Currencies.Default + : contract.Currency; + + switch (contract.SecType?.ToUpperInvariant()) + { + case "STK": + return InstrumentRef.ForStock(contract.Symbol, currency); + + case "OPT": + { + if (!DateTime.TryParseExact(contract.LastTradeDateOrContractMonth, "yyyyMMdd", + CultureInfo.InvariantCulture, DateTimeStyles.None, out var expiry)) + return null; + + var right = contract.Right?.ToUpperInvariant() switch + { + "C" or "CALL" => (OptionRight?)OptionRight.Call, + "P" or "PUT" => OptionRight.Put, + _ => null + }; + if (right is null || contract.Strike <= 0) return null; + + // Die Kontraktgröße kommt als Zeichenkette und fehlt gelegentlich; 100 ist der + // Standard für US-Aktienoptionen und zugleich die Vorgabe von OptionSpec. + var multiplier = int.TryParse(contract.Multiplier, NumberStyles.Integer, + CultureInfo.InvariantCulture, out var m) && m > 0 ? m : 100; + + return InstrumentRef.ForOption(new OptionSpec( + contract.Symbol, DateOnly.FromDateTime(expiry), (decimal)contract.Strike, + right.Value, multiplier, Currency: currency)); + } + + default: + return null; + } + } + /// Baut den Kontrakt passend zum Instrument des Auftrags – Aktie oder Option. public static Contract ForRequest(OrderRequest request) => request.Option is { } option ? Option(option) : Stock(request.Symbol, request.Currency); diff --git a/src/IBKRTrader.Core/Trading/PortfolioService.cs b/src/IBKRTrader.Core/Trading/PortfolioService.cs index 3934bbd..c1fb7d0 100644 --- a/src/IBKRTrader.Core/Trading/PortfolioService.cs +++ b/src/IBKRTrader.Core/Trading/PortfolioService.cs @@ -125,6 +125,64 @@ public sealed class PortfolioService : IPortfolioService $"(Bestand {applied.Quantity:+#;-#;0})."); } + /// + /// Setzt den Bestand einer Position auf einen festgestellten Wert – für den Abgleich mit dem + /// Broker. Zuteilung, Verfall und von Hand angelegte Bestände ändern das Konto ohne eine Order + /// von uns; diese Methode zieht die Buchführung nach. + /// + /// Kein Eintrag in die Handelshistorie. Wir haben nicht gehandelt – ein Trade, den + /// es nie gab, wäre in jeder späteren Auswertung eine Erfindung. Die Kapitalbindung wird + /// dagegen sehr wohl nachgeführt, sonst zeigte das Budget eine Bindung für eine Position, die + /// nicht mehr existiert. + /// + /// Die Korrektur wird als Warnung protokolliert, nicht als Information: dass Buchführung + /// und Konto auseinandergelaufen sind, ist nie belanglos – auch dann nicht, wenn die Ursache + /// harmlos ist. + /// + public async Task ApplyAdjustmentAsync( + string module, InstrumentRef instrument, int newQuantity, decimal avgPrice, + string reason, CancellationToken ct = default) + { + decimal boundBefore, boundAfter; + int previous; + + await using (var db = await _dbf.CreateDbContextAsync(ct)) + { + var pos = await db.Positions.FindAsync([module, instrument.Key], ct); + previous = pos?.Quantity ?? 0; + + if (previous == newQuantity) return; + + boundBefore = BoundCapital(module, instrument, previous, pos?.AvgPrice ?? 0m); + boundAfter = BoundCapital(module, instrument, newQuantity, avgPrice); + + if (newQuantity == 0) + { + if (pos is not null) db.Positions.Remove(pos); + } + else if (pos is null) + { + db.Positions.Add(NewRow(module, instrument, + new PositionMath.Applied(newQuantity, avgPrice, ClosedQuantity: 0))); + } + else + { + pos.Quantity = newQuantity; + pos.AvgPrice = avgPrice; + pos.UpdatedAt = DateTime.UtcNow; + } + + await db.SaveChangesAsync(ct); + } + + var delta = boundAfter - boundBefore; + if (delta > 0) await _budget.ReserveBudgetAsync(module, delta); + else if (delta < 0) await _budget.ReleaseBudgetAsync(module, -delta); + + _logger.Warn(module, + $"Bestand berichtigt: {instrument.Key} {previous:+#;-#;0} -> {newQuantity:+#;-#;0}. {reason}"); + } + /// /// Gebundenes Kapital einer Menge – dieselbe Definition wie , /// bewusst ueber den Positionstyp gefuehrt, damit es davon nur eine Fassung gibt. diff --git a/src/IBKRTrader.Core/Trading/TradingModels.cs b/src/IBKRTrader.Core/Trading/TradingModels.cs index 9eb56a5..2daef30 100644 --- a/src/IBKRTrader.Core/Trading/TradingModels.cs +++ b/src/IBKRTrader.Core/Trading/TradingModels.cs @@ -255,6 +255,16 @@ public sealed record BrokerPosition public decimal MarketValue { get; init; } public decimal UnrealizedPnl { get; init; } + /// + /// Das Instrument, wie wir es führen – null, wenn sich der Kontrakt nicht auf unser + /// Modell abbilden lässt (Futures, Devisen, Fonds). + /// + /// Bewusst nullbar und nicht aus berechnet: Ein Kontrakt, den wir + /// nicht deuten können, darf nicht als Aktie durchgehen. Der Abgleich würde ihn sonst als + /// fremden Aktienbestand buchen und den Bestand des Kontos verfälschen. + /// + public InstrumentRef? Instrument { get; init; } + public override string ToString() => string.Create(CultureInfo.InvariantCulture, $"{Symbol} ({SecType}) {Quantity:N0} @ {AvgCost:N2} {Currency}"); } diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/PositionReconcileWorker.cs b/src/IBKRTrader.Core/Workers/BuiltIn/PositionReconcileWorker.cs new file mode 100644 index 0000000..2a7ee79 --- /dev/null +++ b/src/IBKRTrader.Core/Workers/BuiltIn/PositionReconcileWorker.cs @@ -0,0 +1,56 @@ +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Core.Reconciliation; +using Microsoft.EntityFrameworkCore; + +namespace IBKRTrader.Core.Workers.BuiltIn; + +/// +/// Gleicht die eigene Buchführung periodisch mit dem Bestand des Brokers ab (Kapitalmodell §6). +/// +/// Warum periodisch und nicht nach einem Fill. Zuteilung und Verfall geschehen ohne +/// eine Order von uns – es gibt kein Ereignis, an das man sich hängen könnte. Nach einem +/// Verfallstag führen wir sonst Kontrakte, die es nicht mehr gibt, und 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. +/// +/// Es wird niemals gehandelt. Der Abgleich bucht ausschließlich nach. Eine Software, +/// die eine Abweichung durch eine Order „heilt", handelt auf Grundlage einer Annahme über deren +/// Ursache – und die Ursache kennt sie nicht. +/// +/// Die Arbeit selbst steht in ; derselbe Ablauf ist über +/// --reconcile auch von Hand und als Trockenlauf zu haben. +/// +public class PositionReconcileWorker : WorkerBase +{ + private readonly ReconciliationService _reconciliation; + + public override string Name => "PositionReconcileWorker"; + public override string Module => "Core"; + public override WorkerType Type => WorkerType.Worker; + + /// + /// Viertelstündlich. Häufiger brächte nichts – Zuteilungen bucht der Broker über Nacht –, + /// seltener ließe eine falsche Deckungslage zu lange bestehen. + /// + protected override TimeSpan? Interval => TimeSpan.FromMinutes(15); + + public PositionReconcileWorker( + LoggingService logger, + IDbContextFactory db, + ReconciliationService reconciliation) + : base(logger, db) + { + _reconciliation = reconciliation; + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + var result = await _reconciliation.RunAsync(apply: true, ct); + + if (result.Differences > 0) Logger.Warn(Module, $"Bestandsabgleich – {result.Summary}"); + else if (result.Actions.Count > 0) Logger.Info(Module, $"Bestandsabgleich – {result.Summary}"); + + Info.Info = result.Summary; + } +} diff --git a/src/IBKRTrader.Daemon/Program.cs b/src/IBKRTrader.Daemon/Program.cs index 4aeb0d1..70d78c1 100644 --- a/src/IBKRTrader.Daemon/Program.cs +++ b/src/IBKRTrader.Daemon/Program.cs @@ -1,6 +1,7 @@ using Deploymentcenter.Client; using IBKRTrader.Core.Configuration; using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Reconciliation; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Trading; using IBKRTrader.Core.Modularity; @@ -48,6 +49,9 @@ internal static class Program // Kette. Ohne --confirm ein Trockenlauf. Siehe VerifyOrder fuer die Sicherungen. var verifyOrder = ArgAfter(args, "--verify-order"); + // Bestandsabgleich von Hand. Mit --dry-run wird nur festgestellt, nicht gebucht. + var reconcile = HasFlag(args, "--reconcile"); + var modules = AppHostBuilder.CreateModules(); using var host = AppHostBuilder.Build(modules); @@ -57,13 +61,14 @@ internal static class Program // sie nur in der Logdatei, sah ein CI-Schritt bloß "OK" und Exitcode 0 – auch dann, wenn // dort "kein Master-Key" und "DB ohne SslMode" untereinander lagen. var warnings = 0; - if (checkOnly || ibkrCheck || verifyOrder is not null) + if (checkOnly || ibkrCheck || verifyOrder is not null || reconcile) { logger.EntryWritten += e => { // Bei der IBKR-Prüfung zählt auch, was der Adapter an Info meldet: Zeitkontext, // ServerVersion und Kontoliste stehen dort und sind der halbe Befund. - var floor = ibkrCheck || verifyOrder is not null ? AppLogLevel.Info : AppLogLevel.Warn; + var floor = ibkrCheck || verifyOrder is not null || reconcile + ? AppLogLevel.Info : AppLogLevel.Warn; if (e.Level < floor) return; if (e.Level >= AppLogLevel.Warn) Interlocked.Increment(ref warnings); Console.WriteLine($" [{e.Level}] {e.Module}: {e.Message}"); @@ -75,6 +80,9 @@ internal static class Program if (checkOnly) return await RunCheckAsync(host.Services, logger, warnings).ConfigureAwait(false); + if (reconcile) + return await RunReconcileAsync(host.Services, !HasFlag(args, "--dry-run")).ConfigureAwait(false); + if (ibkrCheck) return await RunIbkrCheckAsync(host.Services, SymbolArg(args) ?? "AAPL").ConfigureAwait(false); @@ -111,6 +119,29 @@ internal static class Program /// die den Schalter besteht, kann sonst vollständig ohne Datenbank dastehen – und genau dafür /// ist er gedacht. /// + /// + /// Bestandsabgleich von Hand. Ohne --dry-run werden die Korrekturen gebucht; + /// gehandelt wird in keinem Fall. + /// + private static async Task RunReconcileAsync(IServiceProvider services, bool apply) + { + var service = services.GetRequiredService(); + var result = await service.RunAsync(apply).ConfigureAwait(false); + + Console.WriteLine(apply + ? "Bestandsabgleich (Korrekturen werden gebucht)" + : "Bestandsabgleich (Trockenlauf – es wird nichts gebucht)"); + Console.WriteLine($"Eigene Positionen: {result.OwnPositions}, beim Broker: {result.BrokerPositions}"); + + foreach (var action in result.Actions) + Console.WriteLine($" [{action.Kind}] {action.Book}: {action.Explanation}"); + + Console.WriteLine(result.Summary); + + // Ausgesetzt heißt: nicht geprüft. Das darf ein aufrufendes Skript nicht als Erfolg lesen. + return result.Skipped ? 1 : 0; + } + private static async Task RunCheckAsync(IServiceProvider services, LoggingService logger, int warnings) { var database = services.GetRequiredService(); diff --git a/src/IBKRTrader.Hosting/AppHostBuilder.cs b/src/IBKRTrader.Hosting/AppHostBuilder.cs index 5336128..742352e 100644 --- a/src/IBKRTrader.Hosting/AppHostBuilder.cs +++ b/src/IBKRTrader.Hosting/AppHostBuilder.cs @@ -8,6 +8,7 @@ using IBKRTrader.Core.Logging; using IBKRTrader.Core.Modularity; using IBKRTrader.Core.Persistence; using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Core.Reconciliation; using IBKRTrader.Core.Security; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Time; @@ -269,7 +270,9 @@ public static class AppHostBuilder // Core-Worker/Services services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -279,6 +282,7 @@ public static class AppHostBuilder // Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable. services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); @@ -288,6 +292,7 @@ public static class AppHostBuilder // Lebenszyklus über den Generic Host (jeder Worker ist ein IHostedService). services.AddHostedService(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); diff --git a/tests/IBKRTrader.Tests/Reconciliation/PositionReconcileWorkerTests.cs b/tests/IBKRTrader.Tests/Reconciliation/PositionReconcileWorkerTests.cs new file mode 100644 index 0000000..8d47b46 --- /dev/null +++ b/tests/IBKRTrader.Tests/Reconciliation/PositionReconcileWorkerTests.cs @@ -0,0 +1,107 @@ +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; + +/// +/// Der Worker um den Abgleich. Die Deutung selbst ist in +/// geprüft – hier geht es um das Verhalten drumherum, vor allem um den Fall, in dem der Broker +/// nichts meldet. +/// +[Trait("cat", "unit")] +public class PositionReconcileWorkerTests +{ + private sealed class InMemoryFactory(DbContextOptions o) : IDbContextFactory + { + public CoreDbContext CreateDbContext() => new(o); + } + + /// Der Worker ohne DB-Protokollierung; ExecuteAsync wird direkt aufgerufen. + private sealed class TestableWorker : PositionReconcileWorker + { + public TestableWorker(IPortfolioService portfolio, IBrokerPortfolioReader reader) + : base(new LoggingService(), Dbf(), + new ReconciliationService(portfolio, reader, new LoggingService())) { } + + private static IDbContextFactory Dbf() => + new InMemoryFactory(new DbContextOptionsBuilder() + .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(); + portfolio.GetAllPositionsAsync(Arg.Any()) + .Returns>(_ => [new Position("OW", Aapl, 100, 140m)]); + + var reader = Substitute.For(); + reader.GetPositionsAsync(Arg.Any()) + .Returns>(_ => []); + + 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(); + portfolio.GetAllPositionsAsync(Arg.Any()) + .Returns>(_ => []); + + var reader = Substitute.For(); + reader.GetPositionsAsync(Arg.Any()) + .Returns>(_ => []); + + 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(); + portfolio.GetAllPositionsAsync(Arg.Any()) + .Returns>(_ => []); + + var msft = InstrumentRef.ForStock("MSFT"); + var reader = Substitute.For(); + reader.GetPositionsAsync(Arg.Any()) + .Returns>(_ => + [ + 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(i => i.Key == msft.Key), + 50, + 400m, + Arg.Any(), + Arg.Any()); + } +} diff --git a/tests/IBKRTrader.Tests/Reconciliation/PositionReconcilerTests.cs b/tests/IBKRTrader.Tests/Reconciliation/PositionReconcilerTests.cs new file mode 100644 index 0000000..a310b1e --- /dev/null +++ b/tests/IBKRTrader.Tests/Reconciliation/PositionReconcilerTests.cs @@ -0,0 +1,226 @@ +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(); + } +}