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