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:
@@ -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,
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
Reference in New Issue
Block a user