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="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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user