Abweichung wird je Instrument bestimmt, Sperren auch kopflos setzbar

Der erste Lauf gegen die Testmaschine zeigte den Fehler sofort: 2 Stueck SAP in
DIAG, 2 in UNASSIGNED, der Broker fuehrt 4 - alles richtig, aber zeilenweise
verglichen meldete die Ansicht zwei Fehlbestaende. Der Broker kennt unsere
Buecher nicht; vergleichbar ist nur die Summe ueber alle. ManagedPosition traegt
jetzt OwnTotalQuantity, und Diverges misst daran. Zwei Tests halten beide
Richtungen fest: eine aufgeteilte Position ist keine Abweichung, eine echte
verschwindet nicht in der Summe.

Dazu --lock und --instrument-rule fuer den kopflosen Betrieb. Beide mit
Trockenlauf; die Stufe "Locked" schreibt vor dem Setzen aus, dass sie auch das
SCHLIESSEN durch Module verhindert - das gehoert dorthin, wo entschieden wird,
nicht in den Stoerungsfall.

Auf der Testmaschine durchgespielt: GOOG von UNASSIGNED nach MANUAL umgebucht
(Menge und Einstand unveraendert), dann auf CloseOnly gesetzt - beides steht in
der Uebersicht.

473 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-06 12:45:46 +02:00
co-authored by Claude Opus 5
parent 8fd99ee6fa
commit 834f035ff6
4 changed files with 211 additions and 9 deletions
@@ -20,12 +20,21 @@ namespace IBKRTrader.Core.Positions;
/// <param name="Lock">Sperrstufe des Betreibers für dieses Buch und Instrument.</param> /// <param name="Lock">Sperrstufe des Betreibers für dieses Buch und Instrument.</param>
/// <param name="Rule">Regel des Instruments (Handelsverbot, Stückgrenze) – gilt über alle Bücher.</param> /// <param name="Rule">Regel des Instruments (Handelsverbot, Stückgrenze) – gilt über alle Bücher.</param>
/// <param name="BrokerQuantity"> /// <param name="BrokerQuantity">
/// Was der Broker führt. <c>null</c> heißt <b>nicht bekannt</b> und ausdrücklich nicht „null Stück": /// Was der Broker für dieses Instrument führt – <b>kontoweit</b>, denn der Broker kennt keine
/// Ohne Verbindung ist jede Aussage über Abweichungen wertlos. /// Bücher. <c>null</c> heißt <b>nicht bekannt</b> und ausdrücklich nicht „null Stück": Ohne
/// Verbindung ist jede Aussage über Abweichungen wertlos.
/// ///
/// <para>Dezimal und nicht ganzzahlig, weil der Broker es so meldet – Bruchstücke gibt es. Sie hier /// <para>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.</para> /// zu runden hieße, eine Abweichung wegzurechnen, die es gibt.</para>
/// </param> /// </param>
/// <param name="OwnTotalQuantity">
/// Unser Bestand desselben Instruments <b>über alle Bücher</b> – die einzige Zahl, die sich mit
/// <paramref name="BrokerQuantity"/> vergleichen lässt.
///
/// <para>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.</para>
/// </param>
public sealed record ManagedPosition( public sealed record ManagedPosition(
BookId Book, BookId Book,
InstrumentRef Instrument, InstrumentRef Instrument,
@@ -34,7 +43,8 @@ public sealed record ManagedPosition(
decimal BoundCapital, decimal BoundCapital,
PositionLock Lock, PositionLock Lock,
InstrumentRule Rule, InstrumentRule Rule,
decimal? BrokerQuantity) decimal? BrokerQuantity,
int OwnTotalQuantity)
{ {
/// <summary>Herkunft in einem Wort – das, wonach beim Aufräumen zuerst gefragt wird.</summary> /// <summary>Herkunft in einem Wort – das, wonach beim Aufräumen zuerst gefragt wird.</summary>
public string Origin => Book.Kind switch public string Origin => Book.Kind switch
@@ -46,10 +56,16 @@ public sealed record ManagedPosition(
}; };
/// <summary> /// <summary>
/// Weicht unser Bestand von dem des Brokers ab? <c>false</c>, solange der Broker nichts gesagt /// Weicht unser Bestand von dem des Brokers ab? Verglichen wird der Bestand <b>aller</b> Bücher,
/// hat – eine Abweichung, die nur aus fehlenden Daten besteht, ist keine. /// nicht der dieser Zeile – der Broker kennt unsere Aufteilung nicht.
///
/// <para><c>false</c>, solange der Broker nichts gesagt hat: Eine Abweichung, die nur aus
/// fehlenden Daten besteht, ist keine.</para>
/// </summary> /// </summary>
public bool Diverges => BrokerQuantity is { } broker && broker != Quantity; public bool Diverges => BrokerQuantity is { } broker && broker != OwnTotalQuantity;
/// <summary>Teilen sich mehrere Bücher dieses Instrument?</summary>
public bool IsSharedAcrossBooks => OwnTotalQuantity != Quantity;
public bool IsRestricted => Lock != PositionLock.Free || Rule.IsRestrictive; public bool IsRestricted => Lock != PositionLock.Free || Rule.IsRestrictive;
} }
@@ -142,6 +158,13 @@ public sealed class PositionManager
.GroupBy(p => p.Instrument!.Value.Key, StringComparer.OrdinalIgnoreCase) .GroupBy(p => p.Instrument!.Value.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Sum(p => p.Quantity), 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<ManagedPosition>(own.Count); var managed = new List<ManagedPosition>(own.Count);
foreach (var position in own.Where(p => p.Quantity != 0)) foreach (var position in own.Where(p => p.Quantity != 0))
{ {
@@ -162,7 +185,10 @@ public sealed class PositionManager
BoundCapital: position.BoundCapital, BoundCapital: position.BoundCapital,
Lock: await _rules.GetLockAsync(book, position.Instrument, ct).ConfigureAwait(false), Lock: await _rules.GetLockAsync(book, position.Instrument, ct).ConfigureAwait(false),
Rule: await _rules.GetRuleAsync(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) // Bestand, den der Broker fuehrt und den wir in keinem Buch haben. Der Abgleich (#306)
+112 -2
View File
@@ -56,13 +56,22 @@ internal static class PositionTool
Console.WriteLine("und aus dem einen darf nicht auf das andere geschlossen werden."); 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) if (diverging.Count > 0)
{ {
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("=== Abweichungen gegen den Broker ==="); Console.WriteLine("=== Abweichungen gegen den Broker ===");
foreach (var p in diverging) 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."); 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); return parts.Count == 0 ? "" : string.Join(", ", parts);
} }
/// <summary>
/// Setzt die Sperrstufe einer Position. <b>Ohne <c>--confirm</c> ein Trockenlauf.</b>
///
/// <para>Die Stufe <c>Locked</c> verhindert auch das <b>Schließen</b> 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.</para>
/// </summary>
public static async Task<int> 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<PositionLock>(levelName, ignoreCase: true, out var level))
{
Console.Error.WriteLine(
$"ABBRUCH: \"{levelName}\" ist keine Sperrstufe. Möglich sind: " +
string.Join(", ", Enum.GetNames<PositionLock>()));
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<PositionManager>();
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;
}
/// <summary>
/// Setzt die Regel eines Instruments: Handelsverbot und/oder harte Stückgrenze über alle
/// Bücher. Ohne <c>--blocked</c> und ohne <c>--max</c> wird die Regel gelöscht.
/// </summary>
public static async Task<int> 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<PositionManager>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
await manager.SetRuleAsync(key, rule, note, cts.Token).ConfigureAwait(false);
Console.WriteLine("Gesetzt.");
return 0;
}
/// <summary> /// <summary>
/// Hängt eine Position um. <b>Ohne <c>--confirm</c> ein Trockenlauf</b> – eine Umbuchung ändert /// Hängt eine Position um. <b>Ohne <c>--confirm</c> ein Trockenlauf</b> – eine Umbuchung ändert
/// die Buchführung, und wer sie versehentlich auslöst, merkt es erst an einer falschen Zahl. /// die Buchführung, und wer sie versehentlich auslöst, merkt es erst an einer falschen Zahl.
+14
View File
@@ -70,6 +70,10 @@ internal static class Program
// Eine Position umhängen. Braucht --from, --to, --reason und --confirm. // Eine Position umhängen. Braucht --from, --to, --reason und --confirm.
var reassign = ArgAfter(args, "--reassign"); 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(); var modules = AppHostBuilder.CreateModules();
using var host = AppHostBuilder.Build(modules); using var host = AppHostBuilder.Build(modules);
@@ -111,6 +115,16 @@ internal static class Program
ArgAfter(args, "--from"), ArgAfter(args, "--to"), ArgAfter(args, "--reason"), ArgAfter(args, "--from"), ArgAfter(args, "--to"), ArgAfter(args, "--reason"),
HasFlag(args, "--confirm")).ConfigureAwait(false); 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) if (openOrders)
return await OrderBookTool.ListAsync(host.Services).ConfigureAwait(false); return await OrderBookTool.ListAsync(host.Services).ConfigureAwait(false);
@@ -248,4 +248,56 @@ public class PositionManagerTests
overview.Positions[0].Lock.Should().Be(PositionLock.CloseOnly); overview.Positions[0].Lock.Should().Be(PositionLock.CloseOnly);
overview.Positions[0].IsRestricted.Should().BeTrue(); 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<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Position>>(
[
new Position("DIAG", Sap, 2, 185m),
new Position(BookId.Unassigned.Value, Sap, 2, 187m)
]));
_broker.GetPositionsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<BrokerPosition>>(
[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<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Position>>(
[
new Position("DIAG", Sap, 2, 185m),
new Position(BookId.Unassigned.Value, Sap, 2, 187m)
]));
_broker.GetPositionsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<BrokerPosition>>(
[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");
}
} }