diff --git a/src/IBKRTrader.Core/Positions/PositionManager.cs b/src/IBKRTrader.Core/Positions/PositionManager.cs index 2e1892c..2816f4d 100644 --- a/src/IBKRTrader.Core/Positions/PositionManager.cs +++ b/src/IBKRTrader.Core/Positions/PositionManager.cs @@ -20,12 +20,21 @@ namespace IBKRTrader.Core.Positions; /// Sperrstufe des Betreibers für dieses Buch und Instrument. /// Regel des Instruments (Handelsverbot, Stückgrenze) – gilt über alle Bücher. /// -/// Was der Broker führt. null heißt nicht bekannt und ausdrücklich nicht „null Stück": -/// Ohne Verbindung ist jede Aussage über Abweichungen wertlos. +/// Was der Broker für dieses Instrument führt – kontoweit, denn der Broker kennt keine +/// Bücher. null heißt nicht bekannt und ausdrücklich nicht „null Stück": Ohne +/// Verbindung ist jede Aussage über Abweichungen wertlos. /// /// Dezimal und nicht ganzzahlig, weil der Broker es so meldet – Bruchstücke gibt es. Sie hier /// zu runden hieße, eine Abweichung wegzurechnen, die es gibt. /// +/// +/// Unser Bestand desselben Instruments über alle Bücher – die einzige Zahl, die sich mit +/// vergleichen lässt. +/// +/// Ohne sie meldete jede Aufteilung eine Abweichung: Liegen 2 Stück SAP in DIAG und 2 in +/// UNASSIGNED, während der Broker 4 führt, stimmt alles – zeilenweise verglichen sähe es nach zwei +/// Fehlbeständen aus. Am 2026-09-06 auf der Test-VM genau so beobachtet. +/// public sealed record ManagedPosition( BookId Book, InstrumentRef Instrument, @@ -34,7 +43,8 @@ public sealed record ManagedPosition( decimal BoundCapital, PositionLock Lock, InstrumentRule Rule, - decimal? BrokerQuantity) + decimal? BrokerQuantity, + int OwnTotalQuantity) { /// Herkunft in einem Wort – das, wonach beim Aufräumen zuerst gefragt wird. public string Origin => Book.Kind switch @@ -46,10 +56,16 @@ public sealed record ManagedPosition( }; /// - /// Weicht unser Bestand von dem des Brokers ab? false, solange der Broker nichts gesagt - /// hat – eine Abweichung, die nur aus fehlenden Daten besteht, ist keine. + /// Weicht unser Bestand von dem des Brokers ab? Verglichen wird der Bestand aller Bücher, + /// nicht der dieser Zeile – der Broker kennt unsere Aufteilung nicht. + /// + /// false, solange der Broker nichts gesagt hat: Eine Abweichung, die nur aus + /// fehlenden Daten besteht, ist keine. /// - public bool Diverges => BrokerQuantity is { } broker && broker != Quantity; + public bool Diverges => BrokerQuantity is { } broker && broker != OwnTotalQuantity; + + /// Teilen sich mehrere Bücher dieses Instrument? + public bool IsSharedAcrossBooks => OwnTotalQuantity != Quantity; public bool IsRestricted => Lock != PositionLock.Free || Rule.IsRestrictive; } @@ -142,6 +158,13 @@ public sealed class PositionManager .GroupBy(p => p.Instrument!.Value.Key, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.Sum(p => p.Quantity), StringComparer.OrdinalIgnoreCase); + // Unser Bestand je Instrument ueber alle Buecher - die einzige Groesse, die sich mit dem + // Broker vergleichen laesst. + var ownTotals = own + .Where(p => p.Quantity != 0) + .GroupBy(p => p.Instrument.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Sum(p => p.Quantity), StringComparer.OrdinalIgnoreCase); + var managed = new List(own.Count); foreach (var position in own.Where(p => p.Quantity != 0)) { @@ -162,7 +185,10 @@ public sealed class PositionManager BoundCapital: position.BoundCapital, Lock: await _rules.GetLockAsync(book, position.Instrument, ct).ConfigureAwait(false), Rule: await _rules.GetRuleAsync(position.Instrument, ct).ConfigureAwait(false), - BrokerQuantity: brokerQuantity)); + BrokerQuantity: brokerQuantity, + OwnTotalQuantity: ownTotals.TryGetValue(position.Instrument.Key, out var total) + ? total + : position.Quantity)); } // Bestand, den der Broker fuehrt und den wir in keinem Buch haben. Der Abgleich (#306) diff --git a/src/IBKRTrader.Daemon/PositionTool.cs b/src/IBKRTrader.Daemon/PositionTool.cs index daf668d..0231eb8 100644 --- a/src/IBKRTrader.Daemon/PositionTool.cs +++ b/src/IBKRTrader.Daemon/PositionTool.cs @@ -56,13 +56,22 @@ internal static class PositionTool Console.WriteLine("und aus dem einen darf nicht auf das andere geschlossen werden."); } - var diverging = overview.Positions.Where(p => p.Diverges).ToList(); + // Je Instrument, nicht je Zeile: Der Broker kennt unsere Buecher nicht, und eine auf zwei + // Buecher verteilte Position ist keine Abweichung. + var diverging = overview.Positions + .Where(p => p.Diverges) + .GroupBy(p => p.Instrument.Key, StringComparer.Ordinal) + .Select(g => g.First()) + .ToList(); + if (diverging.Count > 0) { Console.WriteLine(); Console.WriteLine("=== Abweichungen gegen den Broker ==="); foreach (var p in diverging) - Console.WriteLine($" {p.Instrument.Key}: wir {p.Quantity}, Broker {p.BrokerQuantity}"); + Console.WriteLine( + $" {p.Instrument.Key}: wir {p.OwnTotalQuantity} über alle Bücher, " + + $"Broker {p.BrokerQuantity}"); Console.WriteLine("Zum Auflösen: --reconcile --dry-run, dann ohne --dry-run."); } @@ -100,6 +109,107 @@ internal static class PositionTool return parts.Count == 0 ? "" : string.Join(", ", parts); } + /// + /// Setzt die Sperrstufe einer Position. Ohne --confirm ein Trockenlauf. + /// + /// Die Stufe Locked verhindert auch das Schließen durch Module. Das ist eine + /// zulässige Entscheidung des Betreibers, aber sie muss beim Setzen dastehen – nicht erst dann, + /// wenn eine Position aus Risikogründen geschlossen werden müsste und liegen bleibt. + /// + public static async Task LockAsync( + IServiceProvider services, string instrumentKey, string? bookName, string? levelName, + string? note, bool confirm) + { + if (string.IsNullOrWhiteSpace(bookName)) + { + Console.Error.WriteLine("ABBRUCH: --book ist Pflicht – eine Sperre gilt je Buch und Instrument."); + return 1; + } + + if (!Enum.TryParse(levelName, ignoreCase: true, out var level)) + { + Console.Error.WriteLine( + $"ABBRUCH: \"{levelName}\" ist keine Sperrstufe. Möglich sind: " + + string.Join(", ", Enum.GetNames())); + return 1; + } + + if (!InstrumentRef.TryParse(instrumentKey, out var instrument)) + { + Console.Error.WriteLine($"ABBRUCH: \"{instrumentKey}\" ist kein lesbarer Instrumentenschlüssel."); + return 1; + } + + BookId book; + try { book = BookId.Parse(bookName); } + catch (ArgumentException ex) { Console.Error.WriteLine($"ABBRUCH: {ex.Message}"); return 1; } + + Console.WriteLine($"Vorhaben: {instrument.Key} in {book.Value} auf {level}."); + if (level == PositionLock.Locked) + { + Console.WriteLine(); + Console.WriteLine("ACHTUNG: Diese Stufe verhindert auch, dass ein Modul die Position SCHLIESST."); + Console.WriteLine("Eine Position, die aus Risikogründen geschlossen werden müsste, bleibt liegen,"); + Console.WriteLine("bis die Sperre gelöst wird."); + Console.WriteLine(); + } + + if (!confirm) + { + Console.WriteLine("=== TROCKENLAUF – nichts gesetzt ==="); + Console.WriteLine("Zum Ausführen denselben Aufruf mit --confirm wiederholen."); + return 0; + } + + var manager = services.GetRequiredService(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + + await manager.SetLockAsync(book, instrument, level, note, cts.Token).ConfigureAwait(false); + Console.WriteLine($"Gesetzt: {instrument.Key} in {book.Value} auf {level}."); + return 0; + } + + /// + /// Setzt die Regel eines Instruments: Handelsverbot und/oder harte Stückgrenze über alle + /// Bücher. Ohne --blocked und ohne --max wird die Regel gelöscht. + /// + public static async Task RuleAsync( + IServiceProvider services, string key, bool blocked, string? maxText, string? note, bool confirm) + { + int? max = null; + if (!string.IsNullOrWhiteSpace(maxText)) + { + if (!int.TryParse(maxText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || + parsed < 0) + { + Console.Error.WriteLine($"ABBRUCH: \"{maxText}\" ist keine Stückgrenze."); + return 1; + } + max = parsed; + } + + var rule = new InstrumentRule(blocked, max); + + Console.WriteLine($"Vorhaben: {key.ToUpperInvariant()} – " + + (rule.IsRestrictive + ? (blocked ? "Handel gesperrt" : "Handel erlaubt") + + (max is { } m ? FormattableString.Invariant($", höchstens {m} Stück") : "") + : "Regel wird gelöscht")); + + if (!confirm) + { + Console.WriteLine("=== TROCKENLAUF – nichts gesetzt ==="); + return 0; + } + + var manager = services.GetRequiredService(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + + await manager.SetRuleAsync(key, rule, note, cts.Token).ConfigureAwait(false); + Console.WriteLine("Gesetzt."); + return 0; + } + /// /// Hängt eine Position um. Ohne --confirm ein Trockenlauf – eine Umbuchung ändert /// die Buchführung, und wer sie versehentlich auslöst, merkt es erst an einer falschen Zahl. diff --git a/src/IBKRTrader.Daemon/Program.cs b/src/IBKRTrader.Daemon/Program.cs index d3eed02..8c6bf73 100644 --- a/src/IBKRTrader.Daemon/Program.cs +++ b/src/IBKRTrader.Daemon/Program.cs @@ -70,6 +70,10 @@ internal static class Program // Eine Position umhängen. Braucht --from, --to, --reason und --confirm. var reassign = ArgAfter(args, "--reassign"); + // Sperren des Betreibers (#485/#486). Beide brauchen --confirm. + var lockKey = ArgAfter(args, "--lock"); + var ruleKey = ArgAfter(args, "--instrument-rule"); + var modules = AppHostBuilder.CreateModules(); using var host = AppHostBuilder.Build(modules); @@ -111,6 +115,16 @@ internal static class Program ArgAfter(args, "--from"), ArgAfter(args, "--to"), ArgAfter(args, "--reason"), HasFlag(args, "--confirm")).ConfigureAwait(false); + if (lockKey is not null) + return await PositionTool.LockAsync( + host.Services, lockKey, ArgAfter(args, "--book"), ArgAfter(args, "--level"), + ArgAfter(args, "--note"), HasFlag(args, "--confirm")).ConfigureAwait(false); + + if (ruleKey is not null) + return await PositionTool.RuleAsync( + host.Services, ruleKey, HasFlag(args, "--blocked"), ArgAfter(args, "--max"), + ArgAfter(args, "--note"), HasFlag(args, "--confirm")).ConfigureAwait(false); + if (openOrders) return await OrderBookTool.ListAsync(host.Services).ConfigureAwait(false); diff --git a/tests/IBKRTrader.Tests/Positions/PositionManagerTests.cs b/tests/IBKRTrader.Tests/Positions/PositionManagerTests.cs index b83f1cd..ac9b3d0 100644 --- a/tests/IBKRTrader.Tests/Positions/PositionManagerTests.cs +++ b/tests/IBKRTrader.Tests/Positions/PositionManagerTests.cs @@ -248,4 +248,56 @@ public class PositionManagerTests overview.Positions[0].Lock.Should().Be(PositionLock.CloseOnly); overview.Positions[0].IsRestricted.Should().BeTrue(); } + + [Fact] + public async Task Overview_DoesNotCallASplitPositionADivergence() + { + // Am 2026-09-06 auf der Test-VM aufgefallen: 2 Stück SAP in DIAG, 2 in UNASSIGNED, Broker + // führt 4 - alles richtig. Zeilenweise verglichen meldete die Ansicht zwei Fehlbestände. + // Der Broker kennt unsere Bücher nicht; vergleichbar ist nur die Summe. + var sut = CreateSut(out _); + + _portfolio.GetAllPositionsAsync(Arg.Any()) + .Returns(Task.FromResult>( + [ + new Position("DIAG", Sap, 2, 185m), + new Position(BookId.Unassigned.Value, Sap, 2, 187m) + ])); + + _broker.GetPositionsAsync(Arg.Any()) + .Returns(Task.FromResult>( + [new BrokerPosition { Symbol = "SAP", SecType = "STK", Currency = "EUR", + ConId = 1, Quantity = 4, AvgCost = 186m, Instrument = Sap }])); + + var overview = await sut.GetOverviewAsync(); + + overview.Positions.Should().HaveCount(2); + overview.Positions.Should().OnlyContain(p => !p.Diverges, "2 + 2 sind die 4 des Brokers"); + overview.Positions.Should().OnlyContain(p => p.OwnTotalQuantity == 4); + overview.Positions.Should().OnlyContain(p => p.IsSharedAcrossBooks); + } + + [Fact] + public async Task Overview_StillFindsARealDivergenceAcrossBooks() + { + // Die Gegenprobe: Summiert wird, aber nicht weggerechnet. Fehlt beim Broker ein Stück, + // muss es auffallen - sonst hätte die Zusammenfassung die Prüfung abgeschafft. + var sut = CreateSut(out _); + + _portfolio.GetAllPositionsAsync(Arg.Any()) + .Returns(Task.FromResult>( + [ + new Position("DIAG", Sap, 2, 185m), + new Position(BookId.Unassigned.Value, Sap, 2, 187m) + ])); + + _broker.GetPositionsAsync(Arg.Any()) + .Returns(Task.FromResult>( + [new BrokerPosition { Symbol = "SAP", SecType = "STK", Currency = "EUR", + ConId = 1, Quantity = 3, AvgCost = 186m, Instrument = Sap }])); + + var overview = await sut.GetOverviewAsync(); + + overview.Positions.Should().OnlyContain(p => p.Diverges, "wir führen 4, der Broker 3"); + } }