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