Positionsverwaltung: Herkunft sichtbar, Zuordnung aenderbar, Sperren einstellbar

Erster Teil von #485. Der PositionManager im Core beantwortet drei Fragen, die
sich beim Debuggen sofort stellen: Wem gehoert eine Position, was sagt der Broker
dazu, und was ist gesperrt.

Drei Zugaenge auf denselben Dienst - das Fenster der Shell, --positions und
--reassign im kopflosen Betrieb, GET /api/positions. Der Testbetrieb laeuft als
systemd-Dienst; ein Werkzeug, das nur in der Oberflaeche existiert, fehlt genau
dann, wenn man es braucht.

Entscheidungen, die im Code begruendet stehen:

- BrokerQuantity ist nullable und heisst "nicht bekannt", nicht "null Stueck".
  Ohne Verbindung sieht ein leergeraeumtes Konto genauso aus wie eine Stoerung;
  aus dem einen darf nicht auf das andere geschlossen werden.
- Umbuchen aendert die Zuordnung, nie die Menge. Eine Umbuchung, die eine
  Stueckzahl bewegt, waere ein Handel ohne Order und stuende in keiner Historie.
- Gegen eine entgegengesetzte Position wird abgelehnt: Das waere eine
  Glattstellung und gehoert ueber eine Order gemacht, mit Preis und Historie.
- Das Quellbuch ist Pflicht - Schutz gegen die falsche Zeile -, ebenso eine
  Begruendung, sonst ist der Eingriff spaeter nicht mehr erklaerbar.
- Das Cap des Zielbuchs sperrt NICHT. Die Position existiert bereits; sie zu
  benennen erzeugt kein neues Risiko. Wuerde die Grenze das verhindern, bliebe
  jede zu grosse Position fuer immer in UNASSIGNED.
- Der REST-Endpunkt ist lesend. Der Listener kennt keine Anmeldung; ein
  schreibender Zugriff waere ein Eingriff in die Buecher, den niemand belegen kann.

Beim ersten Testlauf gefunden: (Module, ContractKey) ist der Primaerschluessel
von core_position, das Buch laesst sich in der Zeile nicht ueberschreiben.
Umgebucht wird deshalb durch Loeschen und Neuanlegen.

471 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-06 12:41:06 +02:00
co-authored by Claude Opus 5
parent c87b0195fd
commit 8fd99ee6fa
10 changed files with 1116 additions and 4 deletions
+7
View File
@@ -32,6 +32,13 @@ public static class CoreViews
CreateView = () => new WorkersWindow(host, sp.GetRequiredService<WorkerEngine>())
});
host.RegisterView(new ModuleView
{
Id = "core.positions", Title = "Positionen", Group = "Core", Order = 15,
CreateView = () => new PositionsWindow(
host, sp.GetRequiredService<Core.Positions.PositionManager>())
});
host.RegisterView(new ModuleView
{
Id = "core.logs", Title = "Logs", Group = "Core", Order = 20,
+13
View File
@@ -22,6 +22,19 @@ public sealed record LogRow(string Text, IBrush Color);
/// <summary>Eine offene Position im Modul-Fenster.</summary>
public sealed record PositionRow(string Symbol, int Quantity, decimal AvgPrice, decimal Notional);
/// <summary>
/// Eine Zeile der Positionsverwaltung (#485). Zahlen kommen als fertiger Text: Die Formatierung
/// gehoert an eine Stelle, und „Broker“ muss zwischen „null Stück“ und „keine Auskunft“
/// unterscheiden koennen - ein Zahlentyp kann das nicht.
/// </summary>
public sealed record ManagedPositionRow(
string Book, string Origin, string Instrument, string Quantity, string AvgPrice,
string Bound, string Broker, string Restrictions);
/// <summary>Bestand, den der Broker fuehrt und den kein Buch beansprucht.</summary>
public sealed record UnknownPositionRow(
string Symbol, string SecType, string Currency, string Quantity, string AvgCost, string Note);
/// <summary>Eine Zeile der Supervisor-Berichtsliste. Tokens als fertiger Text (Prompt / Completion).</summary>
public sealed record SupervisorReportRow(
DateTime CreatedAt, string Profile, string Model, string Question, int ToolCallCount, string Tokens);
@@ -0,0 +1,78 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dg="clr-namespace:Avalonia.Controls;assembly=Avalonia.Controls.DataGrid"
xmlns:vm="clr-namespace:IBKRTrader.App.ViewModels"
x:Class="IBKRTrader.App.Views.PositionsWindow"
Title="Positionen"
Width="1280" Height="720"
MinWidth="900" MinHeight="480"
WindowStartupLocation="CenterScreen">
<DockPanel>
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="RefreshButton" Content="Aktualisieren"
ToolTip.Tip="Liest Buchführung und Bestand des Brokers neu ein." />
<CheckBox x:Name="IncludeBrokerCheck" Content="Broker abfragen" IsChecked="True"
ToolTip.Tip="Ohne Häkchen wird nur die eigene Buchführung gezeigt – das geht sofort." />
<Rectangle Width="1" Fill="#40808080" Margin="6,2" />
<TextBlock Text="Sperre:" VerticalAlignment="Center" />
<ComboBox x:Name="LockBox" Width="200"
ToolTip.Tip="Gilt für die ausgewählte Position." />
<Button x:Name="ApplyLockButton" Content="Sperre setzen" />
<Rectangle Width="1" Fill="#40808080" Margin="6,2" />
<TextBlock Text="Umbuchen nach:" VerticalAlignment="Center" />
<ComboBox x:Name="TargetBookBox" Width="160" />
<TextBox x:Name="ReasonBox" Width="240" Watermark="Grund (Pflicht)" />
<Button x:Name="ReassignButton" Content="Umbuchen" />
</StackPanel>
</Border>
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock x:Name="StatusText" TextWrapping="Wrap" />
</Border>
<Grid RowDefinitions="*,Auto,140">
<dg:DataGrid x:Name="PositionsGrid" Grid.Row="0"
AutoGenerateColumns="False" SelectionMode="Single" IsReadOnly="True"
x:DataType="vm:ManagedPositionRow">
<dg:DataGrid.Columns>
<dg:DataGridTextColumn Header="Buch" Binding="{Binding Book}" Width="100" />
<dg:DataGridTextColumn Header="Herkunft" Binding="{Binding Origin}" Width="150" />
<dg:DataGridTextColumn Header="Instrument" Binding="{Binding Instrument}" Width="220" />
<dg:DataGridTextColumn Header="Menge" Binding="{Binding Quantity}" Width="90" />
<dg:DataGridTextColumn Header="Einstand" Binding="{Binding AvgPrice}" Width="100" />
<dg:DataGridTextColumn Header="Gebunden" Binding="{Binding Bound}" Width="120" />
<dg:DataGridTextColumn Header="Broker" Binding="{Binding Broker}" Width="90" />
<dg:DataGridTextColumn Header="Sperren" Binding="{Binding Restrictions}" Width="*" />
</dg:DataGrid.Columns>
</dg:DataGrid>
<TextBlock Grid.Row="1" x:Name="UnknownHeader" Margin="8,10,8,4" FontWeight="SemiBold"
Text="Beim Broker, in keinem Buch" IsVisible="False" />
<!-- Bestand, den der Broker führt und den wir niemandem zuordnen können. Er steht
bewusst getrennt und nicht zwischen den eigenen Zeilen: Es ist eine andere Art von
Aussage – nicht „gehört jemandem", sondern „gehört niemandem, den wir kennen". -->
<dg:DataGrid x:Name="UnknownGrid" Grid.Row="2"
AutoGenerateColumns="False" SelectionMode="Single" IsReadOnly="True"
IsVisible="False" x:DataType="vm:UnknownPositionRow">
<dg:DataGrid.Columns>
<dg:DataGridTextColumn Header="Symbol" Binding="{Binding Symbol}" Width="120" />
<dg:DataGridTextColumn Header="Typ" Binding="{Binding SecType}" Width="80" />
<dg:DataGridTextColumn Header="Währung" Binding="{Binding Currency}" Width="90" />
<dg:DataGridTextColumn Header="Menge" Binding="{Binding Quantity}" Width="100" />
<dg:DataGridTextColumn Header="Einstand" Binding="{Binding AvgCost}" Width="110" />
<dg:DataGridTextColumn Header="Hinweis" Binding="{Binding Note}" Width="*" />
</dg:DataGrid.Columns>
</dg:DataGrid>
</Grid>
</DockPanel>
</Window>
@@ -0,0 +1,217 @@
using System.Globalization;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using IBKRTrader.App.Shell;
using IBKRTrader.App.ViewModels;
using IBKRTrader.Core.Capital;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Positions;
using IBKRTrader.Core.Time;
using IBKRTrader.Core.Trading;
namespace IBKRTrader.App.Views;
/// <summary>
/// Core-Ansicht: die Positionsverwaltung (#485). Zeigt, wem eine Position gehört, was der Broker
/// dazu sagt und welche Sperren gelten – und erlaubt beides zu ändern.
///
/// <para><b>Die Logik liegt nicht hier.</b> Dieses Fenster ruft <see cref="PositionManager"/> auf
/// und formatiert dessen Antwort. Eine Umbuchung, die nur über die Oberfläche ginge, wäre auf einer
/// kopflosen Instanz nicht durchführbar – dort gibt es dieselben Aufrufe über
/// <c>--positions</c> und <c>--reassign</c>.</para>
///
/// <para>Datenbank- und Broker-Zugriffe laufen NUR auf Anzeige und Nutzerinteraktion, nie im
/// Konstruktor: <c>--smoke-ui</c> prüft die Fenster-Konstruktion ohne Datenbank.</para>
/// </summary>
public partial class PositionsWindow : Window
{
private readonly PositionManager _manager;
/// <summary>Die Ziele einer Umbuchung. Reservierte Bücher, plus alles, was Module halten.</summary>
private static readonly string[] ReassignTargets =
[BookId.Manual.Value, BookId.Unassigned.Value, BookId.House.Value, BookId.Diagnostics.Value];
private static readonly (string Label, PositionLock Level)[] LockLevels =
[
("frei – keine Sperre", PositionLock.Free),
("nur abbauen (kein Zukauf)", PositionLock.CloseOnly),
("nur aufbauen (kein Verkauf)", PositionLock.OpenOnly),
("ganz gesperrt (auch Schließen!)", PositionLock.Locked)
];
public PositionsWindow(IModuleUiHost uiHost, PositionManager manager)
{
_manager = manager;
InitializeComponent();
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "core.positions");
this.FindControl<ComboBox>("LockBox")!.ItemsSource = LockLevels.Select(l => l.Label).ToList();
this.FindControl<ComboBox>("LockBox")!.SelectedIndex = 0;
this.FindControl<ComboBox>("TargetBookBox")!.ItemsSource = ReassignTargets;
this.FindControl<Button>("RefreshButton")!.Click += async (_, _) => await RefreshAsync();
this.FindControl<Button>("ApplyLockButton")!.Click += async (_, _) => await ApplyLockAsync();
this.FindControl<Button>("ReassignButton")!.Click += async (_, _) => await ReassignAsync();
Opened += async (_, _) => await RefreshAsync();
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
// ─── Anzeigen ────────────────────────────────────────────────────────────────
/// <summary>Die zuletzt geladene Fassung – die Grid-Zeilen sind Text und taugen nicht als Quelle.</summary>
private IReadOnlyList<ManagedPosition> _loaded = [];
private async Task RefreshAsync()
{
var inv = CultureInfo.InvariantCulture;
var includeBroker = this.FindControl<CheckBox>("IncludeBrokerCheck")!.IsChecked == true;
try
{
SetStatus(includeBroker ? "Lade Positionen und Bestand des Brokers …" : "Lade Positionen …");
var overview = await _manager.GetOverviewAsync(includeBroker);
_loaded = overview.Positions;
this.FindControl<DataGrid>("PositionsGrid")!.ItemsSource = overview.Positions
.Select(p => new ManagedPositionRow(
Book: p.Book.Value,
Origin: p.Origin,
Instrument: p.Instrument.Key,
Quantity: p.Quantity.ToString("N0", inv),
AvgPrice: p.AvgPrice.ToString("N2", inv),
Bound: p.BoundCapital.ToString("N0", inv) + " " + p.Instrument.Currency,
// Der Gedankenstrich ist nicht Kosmetik: "0" hieße, der Broker hält nichts.
// Ohne Verbindung wissen wir es schlicht nicht.
Broker: p.BrokerQuantity is { } b ? b.ToString("N0", inv) : "–",
Restrictions: Describe(p)))
.ToList();
var unknownGrid = this.FindControl<DataGrid>("UnknownGrid")!;
var unknownHeader = this.FindControl<TextBlock>("UnknownHeader")!;
unknownGrid.ItemsSource = overview.UnknownToUs
.Select(p => new UnknownPositionRow(
p.Symbol, p.SecType, p.Currency,
p.Quantity.ToString("N0", inv), p.AvgCost.ToString("N2", inv),
"Der Abgleich schreibt sie nach UNASSIGNED; von dort zuordnen."))
.ToList();
unknownGrid.IsVisible = overview.UnknownToUs.Count > 0;
unknownHeader.IsVisible = overview.UnknownToUs.Count > 0;
var diverging = overview.Positions.Count(p => p.Diverges);
SetStatus(
$"{overview.Positions.Count} Position(en)" +
(diverging > 0 ? $", {diverging} weichen vom Broker ab" : "") +
(overview.UnknownToUs.Count > 0 ? $", {overview.UnknownToUs.Count} ohne Buch" : "") +
(includeBroker && !overview.BrokerReachable
? ". Der Broker hat nichts gemeldet – die Spalte bleibt leer, weil ein leeres " +
"Konto und eine gestörte Verbindung von hier aus gleich aussehen."
: "."));
}
catch (Exception ex)
{
SetStatus($"Laden fehlgeschlagen: {ex.Message}");
}
}
private static string Describe(ManagedPosition p)
{
var parts = new List<string>(3);
if (p.Lock != PositionLock.Free)
parts.Add(p.Lock switch
{
PositionLock.CloseOnly => "nur abbauen",
PositionLock.OpenOnly => "nur aufbauen",
PositionLock.Locked => "GESPERRT (auch Schließen)",
_ => p.Lock.ToString()
});
if (p.Rule.Blocked) parts.Add("Instrument gesperrt");
if (p.Rule.MaxQuantity is { } max)
parts.Add(FormattableString.Invariant($"höchstens {max} Stück"));
return string.Join(", ", parts);
}
// ─── Eingreifen ──────────────────────────────────────────────────────────────
private ManagedPosition? Selected()
{
var index = this.FindControl<DataGrid>("PositionsGrid")!.SelectedIndex;
return index >= 0 && index < _loaded.Count ? _loaded[index] : null;
}
private async Task ApplyLockAsync()
{
if (Selected() is not { } position)
{
SetStatus("Keine Position ausgewählt.");
return;
}
var index = this.FindControl<ComboBox>("LockBox")!.SelectedIndex;
if (index < 0 || index >= LockLevels.Length) return;
var (label, level) = LockLevels[index];
try
{
await _manager.SetLockAsync(position.Book, position.Instrument, level, "über die Positionsansicht gesetzt");
SetStatus($"{position.Instrument.Key} in {position.Book.Value}: {label}." +
(level == PositionLock.Locked
? " Achtung: Diese Stufe verhindert auch, dass ein Modul die Position schließt."
: ""));
await RefreshAsync();
}
catch (Exception ex)
{
SetStatus($"Sperre nicht gesetzt: {ex.Message}");
}
}
private async Task ReassignAsync()
{
if (Selected() is not { } position)
{
SetStatus("Keine Position ausgewählt.");
return;
}
if (this.FindControl<ComboBox>("TargetBookBox")!.SelectedItem is not string targetName)
{
SetStatus("Kein Zielbuch gewählt.");
return;
}
var reason = this.FindControl<TextBox>("ReasonBox")!.Text ?? "";
try
{
var result = await _manager.ReassignAsync(
position.Instrument, position.Book, BookId.Parse(targetName), reason);
SetStatus(result.Message);
if (result.Success)
{
this.FindControl<TextBox>("ReasonBox")!.Text = "";
await RefreshAsync();
}
}
catch (Exception ex)
{
SetStatus($"Umbuchung fehlgeschlagen: {ex.Message}");
}
}
private void SetStatus(string text) =>
this.FindControl<TextBlock>("StatusText")!.Text = $"{text} | {AppTimeZone.Now:HH:mm:ss}";
}