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