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:
@@ -20,12 +20,21 @@ namespace IBKRTrader.Core.Positions;
|
||||
/// <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="BrokerQuantity">
|
||||
/// Was der Broker führt. <c>null</c> heißt <b>nicht bekannt</b> und ausdrücklich nicht „null Stück":
|
||||
/// Ohne Verbindung ist jede Aussage über Abweichungen wertlos.
|
||||
/// Was der Broker für dieses Instrument führt – <b>kontoweit</b>, denn der Broker kennt keine
|
||||
/// 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
|
||||
/// zu runden hieße, eine Abweichung wegzurechnen, die es gibt.</para>
|
||||
/// </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(
|
||||
BookId Book,
|
||||
InstrumentRef Instrument,
|
||||
@@ -34,7 +43,8 @@ public sealed record ManagedPosition(
|
||||
decimal BoundCapital,
|
||||
PositionLock Lock,
|
||||
InstrumentRule Rule,
|
||||
decimal? BrokerQuantity)
|
||||
decimal? BrokerQuantity,
|
||||
int OwnTotalQuantity)
|
||||
{
|
||||
/// <summary>Herkunft in einem Wort – das, wonach beim Aufräumen zuerst gefragt wird.</summary>
|
||||
public string Origin => Book.Kind switch
|
||||
@@ -46,10 +56,16 @@ public sealed record ManagedPosition(
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Weicht unser Bestand von dem des Brokers ab? <c>false</c>, 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 <b>aller</b> Bücher,
|
||||
/// 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>
|
||||
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;
|
||||
}
|
||||
@@ -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<ManagedPosition>(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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user