L6: IBKRTrader.App.Avalonia -> IBKRTrader.App; letzte WinForms-Spuren raus
Das Suffix ".Avalonia" gab es nur, weil daneben ein WinForms-IBKRTrader.App
stand. Das ist seit L5 weg, also faellt auch das Suffix. Git erkennt alle
Dateien als Umbenennung; Assembly, Wurzel-Namensraum und die
avares://-Ressourcen-URI sind mitgezogen.
Nebeneffekt der Umbenennung: die global::Avalonia-Qualifizierungen entfallen.
Sie waren noetig, weil der Namensraum IBKRTrader.App.Avalonia das
Avalonia-Paket verdeckt hat - ein Ueberbleibsel genau der Namensgebung, die
jetzt weg ist.
Inhaltlich falsch gewordene Aussagen berichtigt - das waren die eigentlichen
Ueberbleibsel, nicht die Kommentare:
- .agents/rules/grundregeln.md schrieb weiterhin "C# .NET 10 WinForms",
RichTextBox-Logging, LauncherForm und PropertyGrid vor. Das ist die Regel,
nach der kuenftig gearbeitet wird - sie haette die Portierung Stueck fuer
Stueck rueckgaengig gemacht. Jetzt: Avalonia, keine Plattform-Suffixe, die
11er-Pinnung mit Begruendung, dazu die beiden Regeln, die uns in L1b am
meisten gekostet haben (UTC persistieren + AppTimeZone statt DateTime.Now;
jede Formatierung mit ausdruecklichem IFormatProvider).
- Core: LogEntry ("wird in RichTextBox geschrieben"), IWorker/WorkerEngine/
WorkerInfo ("DataGridView-Zeile"/"-Binding"), ModuleView ("die
WinForms-Shell castet auf Form").
- Doku: ARCHITECTURE (Modul-Ui-Ordner, "designbare Forms mit Initialize"),
KONZEPT-Modul-Accounting ("UI (WinForms, ein Fenster mit Tabs)").
BEWUSST STEHEN GEBLIEBEN sind die Kommentare, die WinForms nur als
Begruendung nennen - warum LoggingService ein Ereignis hat statt einer
RichTextBox, warum ModuleView Func<object> liefert, warum es benannte
Record-Zeilentypen gibt, warum die Einstellungsmaske aus Attributen entsteht.
Das ist die Herleitung des heutigen Entwurfs; ohne sie sieht spaeter jede
dieser Stellen nach Umstaendlichkeit ohne Grund aus.
KONZEPT-Linux-Portierung.md bekommt einen Statusvermerk: umgesetzt, die
Pfadangaben im Fundstellenverzeichnis beziehen sich auf den alten Aufbau.
Zwei Abweichungen von der Schaetzung sind dort festgehalten - der geringere
Aufwand dank der PolytraderSharp-Vorlage, und dass die dort empfohlene
InvariantGlobalization ein Fehler gewesen waere.
Verifiziert: Build 0 Fehler/0 Warnungen, 193 Tests gruen, Smoke-UI
konstruiert alle 7 Ansichten + Launcher + Dialog, Daemon-Prueflauf OK,
publish -r linux-x64 fuer beide Einstiegspunkte fehlerfrei.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<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.DashboardWindow"
|
||||
Title="Dashboard"
|
||||
Width="960" Height="600"
|
||||
MinWidth="640" MinHeight="420"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<DockPanel>
|
||||
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="RefreshButton" Content="Aktualisieren" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock x:Name="StatusText" />
|
||||
</Border>
|
||||
|
||||
<Grid Margin="12" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<TextBlock Grid.Row="0" x:Name="ModeText"
|
||||
FontSize="16" FontWeight="SemiBold" Margin="0,0,0,10" />
|
||||
|
||||
<!-- Kennzahlen als Kacheln statt einer langen Label-Zeile. -->
|
||||
<WrapPanel Grid.Row="1" x:Name="KpiPanel" />
|
||||
|
||||
<TextBlock Grid.Row="2" Classes="section" Text="Geladene Module" />
|
||||
|
||||
<dg:DataGrid Grid.Row="3" x:Name="ModulesGrid" AutoGenerateColumns="False"
|
||||
x:DataType="vm:ModuleRow">
|
||||
<dg:DataGrid.Columns>
|
||||
<dg:DataGridTextColumn Header="Modul" Binding="{Binding Name}" Width="200" />
|
||||
<dg:DataGridTextColumn Header="Präfix" Binding="{Binding Prefix}" Width="100" />
|
||||
<dg:DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="*" />
|
||||
</dg:DataGrid.Columns>
|
||||
</dg:DataGrid>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,97 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using IBKRTrader.App.Shell;
|
||||
using IBKRTrader.App.ViewModels;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Core.Time;
|
||||
using IBKRTrader.Core.Trading;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace IBKRTrader.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Ansicht: Gesamtüberblick (Handelsmodus, aggregierte Kennzahlen, geladene Module).
|
||||
/// DB-Zugriffe laufen NUR auf Anzeige und Nutzerinteraktion – nie im Konstruktor, damit die
|
||||
/// Konstruktionsprüfung (<c>--smoke-ui</c>) auch ohne Datenbank fehlerfrei durchläuft.
|
||||
/// </summary>
|
||||
public partial class DashboardWindow : Window
|
||||
{
|
||||
private readonly DashboardService _dashboard;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly IReadOnlyList<IModule> _modules;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly int _workerCount;
|
||||
|
||||
public DashboardWindow(IModuleUiHost uiHost,
|
||||
DashboardService dashboard,
|
||||
SettingsService settings,
|
||||
IEnumerable<IModule> modules,
|
||||
IConfiguration config,
|
||||
IEnumerable<IWorker> workers)
|
||||
{
|
||||
_dashboard = dashboard;
|
||||
_settings = settings;
|
||||
_modules = modules.ToList();
|
||||
_config = config;
|
||||
_workerCount = workers.Count();
|
||||
|
||||
InitializeComponent();
|
||||
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "core.dashboard");
|
||||
|
||||
this.FindControl<Button>("RefreshButton")!.Click += async (_, _) => await RefreshAsync();
|
||||
Opened += async (_, _) => await RefreshAsync();
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
var t = _settings.Settings.Trading;
|
||||
var modeText = this.FindControl<TextBlock>("ModeText")!;
|
||||
modeText.Text = $"Trading: {t.Mode} – {(t.TradingEnabled ? "AKTIV" : "inaktiv")}";
|
||||
modeText.Foreground = t.TradingEnabled ? Brushes.SeaGreen : Brushes.Gray;
|
||||
|
||||
this.FindControl<DataGrid>("ModulesGrid")!.ItemsSource = _modules
|
||||
.Select(m => new ModuleRow(m.Name, m.DbPrefix, m.GetActivationBlocker(_config) ?? "aktivierbar"))
|
||||
.ToList();
|
||||
|
||||
var status = this.FindControl<TextBlock>("StatusText")!;
|
||||
try
|
||||
{
|
||||
var snap = await _dashboard.GetSnapshotAsync();
|
||||
ShowKpis(
|
||||
("Offene Positionen", snap.OpenPositions.ToString()),
|
||||
("Exposure", snap.TotalExposure.ToString("N2")),
|
||||
("Trades gesamt", snap.TotalTrades.ToString()),
|
||||
("Worker / Services", _workerCount.ToString()));
|
||||
status.Text = $"Aktualisiert: {AppTimeZone.Now:HH:mm:ss}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowKpis(
|
||||
("Offene Positionen", "n/v"),
|
||||
("Exposure", "n/v"),
|
||||
("Trades gesamt", "n/v"),
|
||||
("Worker / Services", _workerCount.ToString()));
|
||||
status.Text = $"DB nicht erreichbar: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Baut die Kennzahlen-Kacheln neu. Gestaltung kommt aus der Klasse "kpi" in App.axaml.</summary>
|
||||
private void ShowKpis(params (string Caption, string Value)[] kpis)
|
||||
{
|
||||
var panel = this.FindControl<WrapPanel>("KpiPanel")!;
|
||||
panel.Children.Clear();
|
||||
|
||||
foreach (var (caption, value) in kpis)
|
||||
{
|
||||
var stack = new StackPanel();
|
||||
stack.Children.Add(new TextBlock { Text = caption, Classes = { "caption" } });
|
||||
stack.Children.Add(new TextBlock { Text = value, Classes = { "value" } });
|
||||
panel.Children.Add(new Border { Classes = { "kpi" }, Child = stack });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="IBKRTrader.App.Views.LauncherWindow"
|
||||
Title="IBKRTrader — Launcher"
|
||||
Width="820" Height="560"
|
||||
MinWidth="560" MinHeight="360"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<DockPanel>
|
||||
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<!-- Fensterleiste: je registrierter View eine Schaltfläche, Symbol über Text. -->
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<ItemsControl x:Name="ViewButtons">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock x:Name="StatusText" Text="Start..." />
|
||||
</Border>
|
||||
|
||||
<Border Background="White">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="6">
|
||||
<TextBlock Text="IBKRTrader"
|
||||
FontSize="22" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Text="Fenster über die Leiste oben öffnen."
|
||||
Foreground="#666666"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="EnvironmentText"
|
||||
Foreground="#999999" FontSize="11"
|
||||
Margin="0,12,0,0"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,108 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using IBKRTrader.App.Shell;
|
||||
using IBKRTrader.Core.Time;
|
||||
|
||||
namespace IBKRTrader.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Launcher – das Basisfenster der Shell. Zeigt je registrierter View eine Schaltfläche und trägt
|
||||
/// das gemeinsame Fenster-Menü. Die inhaltlichen Ansichten sind eigenständige Fenster.
|
||||
///
|
||||
/// <para>Die Dienste laufen bereits, wenn dieses Fenster erscheint: der Host wird in
|
||||
/// <c>Program.Main</c> vor Avalonia gestartet. Der Launcher startet nichts, er zeigt nur an.</para>
|
||||
/// </summary>
|
||||
public partial class LauncherWindow : Window
|
||||
{
|
||||
private readonly AvaloniaUiHost _uiHost;
|
||||
private readonly Dictionary<string, Button> _viewButtons = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Parameterloser Konstruktor nur für den XAML-Previewer.</summary>
|
||||
public LauncherWindow() : this(new AvaloniaUiHost(), null) { }
|
||||
|
||||
public LauncherWindow(AvaloniaUiHost uiHost, IServiceProvider? services)
|
||||
{
|
||||
_uiHost = uiHost;
|
||||
InitializeComponent();
|
||||
|
||||
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, _uiHost, currentViewId: null);
|
||||
BuildViewButtons();
|
||||
|
||||
this.FindControl<TextBlock>("EnvironmentText")!.Text =
|
||||
$"{Environment.OSVersion.Platform} · .NET {Environment.Version} · Zeitzone {AppTimeZone.CurrentId}";
|
||||
|
||||
_uiHost.OpenStateChanged += UpdateButtonStates;
|
||||
Closed += (_, _) => _uiHost.OpenStateChanged -= UpdateButtonStates;
|
||||
|
||||
SetStatus(services is null ? "Vorschau" : "Bereit");
|
||||
UpdateButtonStates();
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
protected override void OnClosing(WindowClosingEventArgs e)
|
||||
{
|
||||
// Auch das Schließen-X läuft über die Sicherheitsabfrage.
|
||||
if (!_uiHost.ShutdownConfirmed)
|
||||
{
|
||||
e.Cancel = true;
|
||||
_uiHost.RequestShutdown();
|
||||
return;
|
||||
}
|
||||
base.OnClosing(e);
|
||||
}
|
||||
|
||||
private void BuildViewButtons()
|
||||
{
|
||||
var buttons = new List<Control>();
|
||||
|
||||
foreach (var view in _uiHost.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
|
||||
{
|
||||
var id = view.Id;
|
||||
var content = new StackPanel { Spacing = 2, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
|
||||
var icon = ViewIcons.Resolve(view.IconKey);
|
||||
if (icon is not null)
|
||||
content.Children.Add(new Image
|
||||
{
|
||||
Source = icon, Width = 32, Height = 32,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
});
|
||||
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = view.Title, FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
});
|
||||
|
||||
var button = new Button { Content = content, Padding = new Thickness(10, 6) };
|
||||
button.Click += (_, _) => _uiHost.OpenView(id);
|
||||
|
||||
_viewButtons[id] = button;
|
||||
buttons.Add(button);
|
||||
}
|
||||
|
||||
this.FindControl<ItemsControl>("ViewButtons")!.ItemsSource = buttons;
|
||||
}
|
||||
|
||||
/// <summary>Hebt die Schaltflächen der bereits offenen Fenster hervor.</summary>
|
||||
private void UpdateButtonStates()
|
||||
{
|
||||
if (!Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
Dispatcher.UIThread.Post(UpdateButtonStates);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (id, button) in _viewButtons)
|
||||
button.BorderBrush = _uiHost.IsOpen(id) ? Brushes.SteelBlue : null;
|
||||
}
|
||||
|
||||
private void SetStatus(string text) =>
|
||||
this.FindControl<TextBlock>("StatusText")!.Text =
|
||||
$"Status: {text} | {AppTimeZone.Now:HH:mm:ss}";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:IBKRTrader.App.ViewModels"
|
||||
x:Class="IBKRTrader.App.Views.LogsWindow"
|
||||
Title="Logs"
|
||||
Width="1000" Height="650"
|
||||
MinWidth="640" MinHeight="360"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<DockPanel>
|
||||
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button x:Name="ClearButton" Content="Leeren" />
|
||||
<Button x:Name="CopyButton" Content="Alles kopieren" />
|
||||
<CheckBox x:Name="AutoScrollCheck" Content="Automatisch scrollen"
|
||||
IsChecked="True" Margin="12,0,0,0" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Filter:" Margin="16,0,6,0" VerticalAlignment="Center" />
|
||||
<TextBox x:Name="FilterBox" Width="220" Watermark="Text oder Modul" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock x:Name="StatusText" />
|
||||
</Border>
|
||||
|
||||
<!-- Dunkles Terminal-Erscheinungsbild wie in der bisherigen RichTextBox. Statt der
|
||||
Selection-Einfärbung von WinForms wird hier je Eintrag ein eingefärbtes Element
|
||||
erzeugt – das ist der Weg, den Avalonia dafür vorsieht. -->
|
||||
<ScrollViewer x:Name="LogScroller" Background="#14141E">
|
||||
<ItemsControl x:Name="LogList" Margin="8">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LogRow">
|
||||
<TextBlock Text="{Binding Text}"
|
||||
Foreground="{Binding Color}"
|
||||
FontFamily="Consolas,Menlo,DejaVu Sans Mono,monospace"
|
||||
FontSize="12"
|
||||
TextWrapping="NoWrap" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using IBKRTrader.App.Shell;
|
||||
using IBKRTrader.App.ViewModels;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Time;
|
||||
|
||||
namespace IBKRTrader.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Ansicht: Live-Log, an das <see cref="LoggingService.EntryWritten"/>-Ereignis gebunden.
|
||||
///
|
||||
/// <para>Einfärbung und der Wechsel auf den UI-Thread liegen hier, nicht mehr im Logging-Dienst –
|
||||
/// der Core trägt seit der Portierung keine UI-Abhängigkeit. Der Dienst schreibt aus beliebigen
|
||||
/// Worker-Threads, deshalb geht jeder Eintrag über den Dispatcher.</para>
|
||||
/// </summary>
|
||||
public partial class LogsWindow : Window
|
||||
{
|
||||
/// <summary>
|
||||
/// Obergrenze der angezeigten Zeilen. Ohne sie wüchse die Liste im Dauerbetrieb unbegrenzt –
|
||||
/// die vollständige Historie steht ohnehin in den Logdateien.
|
||||
/// </summary>
|
||||
private const int MaxLines = 5000;
|
||||
|
||||
private static readonly IBrush ColorInfo = new SolidColorBrush(Color.FromRgb(150, 210, 150));
|
||||
private static readonly IBrush ColorWarn = new SolidColorBrush(Color.FromRgb(255, 190, 60));
|
||||
private static readonly IBrush ColorError = new SolidColorBrush(Color.FromRgb(255, 80, 80));
|
||||
|
||||
private readonly LoggingService _logger;
|
||||
private readonly ObservableCollection<LogRow> _rows = [];
|
||||
private string _filter = "";
|
||||
|
||||
public LogsWindow(IModuleUiHost uiHost, LoggingService logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
InitializeComponent();
|
||||
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "core.logs");
|
||||
|
||||
this.FindControl<ItemsControl>("LogList")!.ItemsSource = _rows;
|
||||
|
||||
this.FindControl<Button>("ClearButton")!.Click += (_, _) => { _rows.Clear(); SetStatus("Geleert."); };
|
||||
this.FindControl<Button>("CopyButton")!.Click += async (_, _) => await CopyAllAsync();
|
||||
this.FindControl<TextBox>("FilterBox")!.TextChanged += (s, _) =>
|
||||
_filter = ((TextBox)s!).Text ?? "";
|
||||
|
||||
_logger.EntryWritten += OnEntryWritten;
|
||||
Closed += (_, _) => _logger.EntryWritten -= OnEntryWritten;
|
||||
|
||||
SetStatus("Bereit.");
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
/// <summary>
|
||||
/// Wird aus beliebigen Worker-Threads gerufen. Ein Fehler hier darf den schreibenden Worker
|
||||
/// niemals mitreißen – deshalb der umschließende Schutz.
|
||||
/// </summary>
|
||||
private void OnEntryWritten(LogEntry e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Dispatcher.UIThread.CheckAccess()) Append(e);
|
||||
else Dispatcher.UIThread.Post(() => Append(e));
|
||||
}
|
||||
catch { /* Fenster wird gerade geschlossen */ }
|
||||
}
|
||||
|
||||
private void Append(LogEntry e)
|
||||
{
|
||||
var text = LoggingService.Format(e);
|
||||
|
||||
if (_filter.Length > 0 && !text.Contains(_filter, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
var color = e.Level switch
|
||||
{
|
||||
AppLogLevel.Warn => ColorWarn,
|
||||
AppLogLevel.Error => ColorError,
|
||||
_ => ColorInfo
|
||||
};
|
||||
|
||||
_rows.Add(new LogRow(text, color));
|
||||
while (_rows.Count > MaxLines) _rows.RemoveAt(0);
|
||||
|
||||
if (this.FindControl<CheckBox>("AutoScrollCheck")!.IsChecked == true)
|
||||
this.FindControl<ScrollViewer>("LogScroller")!.ScrollToEnd();
|
||||
}
|
||||
|
||||
private async Task CopyAllAsync()
|
||||
{
|
||||
var clipboard = GetTopLevel(this)?.Clipboard;
|
||||
if (clipboard is null) { SetStatus("Zwischenablage nicht verfügbar."); return; }
|
||||
|
||||
await clipboard.SetTextAsync(string.Join(Environment.NewLine, _rows.Select(r => r.Text)));
|
||||
SetStatus($"{_rows.Count} Zeilen kopiert.");
|
||||
}
|
||||
|
||||
private void SetStatus(string text) =>
|
||||
this.FindControl<TextBlock>("StatusText")!.Text = $"{text} | {AppTimeZone.Now:HH:mm:ss}";
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<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.Modules.AccountingWindow"
|
||||
Title="Accounting"
|
||||
Width="1100" Height="720"
|
||||
MinWidth="820" MinHeight="520"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<DockPanel>
|
||||
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<!-- Gemeinsame Filterleiste über allen Registerkarten. -->
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Von" VerticalAlignment="Center" Margin="0,0,4,0" />
|
||||
<DatePicker x:Name="FromDate" />
|
||||
<TextBlock Text="Bis" VerticalAlignment="Center" Margin="10,0,4,0" />
|
||||
<DatePicker x:Name="ToDate" />
|
||||
<TextBlock Text="Konto" VerticalAlignment="Center" Margin="10,0,4,0" />
|
||||
<ComboBox x:Name="AccountBox" MinWidth="150" />
|
||||
<TextBlock Text="Währung" VerticalAlignment="Center" Margin="10,0,4,0" />
|
||||
<ComboBox x:Name="CurrencyBox" MinWidth="90" />
|
||||
<Button x:Name="RefreshButton" Content="Aktualisieren" Margin="14,0,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock x:Name="StatusText" />
|
||||
</Border>
|
||||
|
||||
<TabControl>
|
||||
|
||||
<!-- ── Übersicht / BWA ─────────────────────────────────────── -->
|
||||
<TabItem Header="Übersicht / BWA">
|
||||
<DockPanel Margin="10">
|
||||
<WrapPanel x:Name="KpiPanel" DockPanel.Dock="Top" />
|
||||
<TextBlock x:Name="CurrencyNote" DockPanel.Dock="Top"
|
||||
Foreground="#666666" FontSize="11" Margin="0,0,0,8" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="section" DockPanel.Dock="Top" Text="Monatsvergleich" />
|
||||
|
||||
<dg:DataGrid x:Name="MonthlyGrid" AutoGenerateColumns="False" x:DataType="vm:MonthlyRow">
|
||||
<dg:DataGrid.Columns>
|
||||
<dg:DataGridTextColumn Header="Monat" Binding="{Binding Month}" Width="90" />
|
||||
<dg:DataGridTextColumn Header="Anfang" Binding="{Binding Opening}" Width="*" />
|
||||
<dg:DataGridTextColumn Header="Einzahlungen" Binding="{Binding Deposits}" Width="*" />
|
||||
<dg:DataGridTextColumn Header="Auszahlungen" Binding="{Binding Withdrawals}" Width="*" />
|
||||
<dg:DataGridTextColumn Header="Volumen" Binding="{Binding Volume}" Width="*" />
|
||||
<dg:DataGridTextColumn Header="Fees" Binding="{Binding Fees}" Width="*" />
|
||||
<dg:DataGridTextColumn Header="Ergebnis" Binding="{Binding Result}" Width="*" />
|
||||
<dg:DataGridTextColumn Header="Endsaldo" Binding="{Binding Closing}" Width="*" />
|
||||
</dg:DataGrid.Columns>
|
||||
</dg:DataGrid>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
|
||||
<!-- ── Ledger ──────────────────────────────────────────────── -->
|
||||
<TabItem Header="Ledger">
|
||||
<dg:DataGrid x:Name="LedgerGrid" AutoGenerateColumns="False" x:DataType="vm:LedgerRow">
|
||||
<dg:DataGrid.Columns>
|
||||
<dg:DataGridTextColumn Header="Zeit (UTC)"
|
||||
Binding="{Binding Time, StringFormat='{}{0:yyyy-MM-dd HH:mm}'}" Width="140" />
|
||||
<dg:DataGridTextColumn Header="Konto" Binding="{Binding AccountId}" Width="110" />
|
||||
<dg:DataGridTextColumn Header="Typ" Binding="{Binding EventType}" Width="110" />
|
||||
<dg:DataGridTextColumn Header="Side" Binding="{Binding Side}" Width="70" />
|
||||
<dg:DataGridTextColumn Header="Symbol" Binding="{Binding Symbol}" Width="100" />
|
||||
<dg:DataGridTextColumn Header="Währung" Binding="{Binding Currency}" Width="80" />
|
||||
<dg:DataGridTextColumn Header="Menge" Binding="{Binding Quantity}" Width="90" />
|
||||
<dg:DataGridTextColumn Header="Preis" Binding="{Binding Price}" Width="90" />
|
||||
<dg:DataGridTextColumn Header="Brutto" Binding="{Binding Gross}" Width="100" />
|
||||
<dg:DataGridTextColumn Header="Fee" Binding="{Binding Fee}" Width="90" />
|
||||
<dg:DataGridTextColumn Header="Netto" Binding="{Binding Net}" Width="100" />
|
||||
<dg:DataGridTextColumn Header="Transaktion" Binding="{Binding TransactionId}" Width="*" />
|
||||
</dg:DataGrid.Columns>
|
||||
</dg:DataGrid>
|
||||
</TabItem>
|
||||
|
||||
<!-- ── Steuer (Platzhalter) ────────────────────────────────── -->
|
||||
<TabItem Header="Steuer">
|
||||
<ScrollViewer>
|
||||
<SelectableTextBlock Margin="16" TextWrapping="Wrap"
|
||||
Text="Die steuerliche Einordnung ist noch offen (Jurisdiktion nicht festgelegt). Der neutrale Ledger und die Periodenabrechnung sind davon unabhängig gültig. Eine konkrete Steuerschicht (z. B. DE-Kapitalertragsteuer oder US Form 8949 / Schedule D) wird hier später als klar dokumentierte, prüfbare Rechenschicht ergänzt. Hinweis: Dies ist keine Steuerberatung." />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ── Abrechnung / Export ─────────────────────────────────── -->
|
||||
<TabItem Header="Abrechnung / Export">
|
||||
<StackPanel Margin="16" Spacing="8" HorizontalAlignment="Left">
|
||||
<TextBlock Text="Exportiert die aktuelle Auswahl (Zeitraum / Konto / Währung):"
|
||||
Margin="0,0,0,4" />
|
||||
<Button x:Name="ExportLedgerCsvButton" Content="Ledger als CSV …" MinWidth="200" />
|
||||
<Button x:Name="ExportStatementCsvButton" Content="Abrechnung als CSV …" MinWidth="200" />
|
||||
<Button x:Name="ExportPdfButton" Content="Abrechnung als PDF …" MinWidth="200" />
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
|
||||
<!-- ── Abruf / Status ──────────────────────────────────────── -->
|
||||
<TabItem Header="Abruf / Status">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="8" Spacing="8">
|
||||
<Button x:Name="IngestIncrementalButton" Content="Inkrementell abrufen" />
|
||||
<Button x:Name="IngestBackfillButton" Content="Backfill (voll)" />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="IngestStatus" DockPanel.Dock="Top" Margin="8,0,8,8"
|
||||
Foreground="#666666" TextWrapping="Wrap" />
|
||||
|
||||
<dg:DataGrid x:Name="RunsGrid" AutoGenerateColumns="False" x:DataType="vm:IngestRunRow">
|
||||
<dg:DataGrid.Columns>
|
||||
<dg:DataGridTextColumn Header="Konto" Binding="{Binding AccountId}" Width="110" />
|
||||
<dg:DataGridTextColumn Header="Start"
|
||||
Binding="{Binding Started, StringFormat='{}{0:dd.MM. HH:mm}'}" Width="110" />
|
||||
<dg:DataGridTextColumn Header="Ende"
|
||||
Binding="{Binding Finished, StringFormat='{}{0:dd.MM. HH:mm}', TargetNullValue='–'}" Width="110" />
|
||||
<dg:DataGridCheckBoxColumn Header="Backfill" Binding="{Binding Backfill}" Width="80" />
|
||||
<dg:DataGridTextColumn Header="Neu" Binding="{Binding NewEntries}" Width="70" />
|
||||
<dg:DataGridTextColumn Header="Duplikate" Binding="{Binding DuplicateEntries}" Width="90" />
|
||||
<dg:DataGridCheckBoxColumn Header="OK" Binding="{Binding Success}" Width="60" />
|
||||
<dg:DataGridTextColumn Header="Delta" Binding="{Binding Delta}" Width="100" />
|
||||
<dg:DataGridTextColumn Header="Meldung" Binding="{Binding Message}" Width="*" />
|
||||
</dg:DataGrid.Columns>
|
||||
</dg:DataGrid>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,291 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform.Storage;
|
||||
using IBKRTrader.App.Shell;
|
||||
using IBKRTrader.App.ViewModels;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Time;
|
||||
using IBKRTrader.Modules.Accounting.Logic;
|
||||
using IBKRTrader.Modules.Accounting.Persistence;
|
||||
using IBKRTrader.Modules.Accounting.Services;
|
||||
|
||||
namespace IBKRTrader.App.Views.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Fenster des Accounting-Moduls: Übersicht/BWA, Ledger, Steuer (Platzhalter),
|
||||
/// Abrechnung/Export und Abruf/Status.
|
||||
///
|
||||
/// <para>Alle DB-Zugriffe laufen NUR auf Nutzerinteraktion – nie im Konstruktor, damit die
|
||||
/// Konstruktionsprüfung das Fenster auch ohne Datenbank fehlerfrei baut.</para>
|
||||
///
|
||||
/// <para>Beträge werden beim Laden gegen <see cref="CultureInfo.InvariantCulture"/> formatiert:
|
||||
/// die Anzeige soll nicht davon abhängen, auf welchem Host die Instanz läuft. Für den PDF-Export
|
||||
/// gilt dieselbe Festlegung an einer eigenen Stelle (fest de-DE).</para>
|
||||
/// </summary>
|
||||
public partial class AccountingWindow : Window
|
||||
{
|
||||
private const string AllAccounts = "(alle)";
|
||||
|
||||
private readonly ILedgerRepository _ledger;
|
||||
private readonly IIngestRunRepository _runs;
|
||||
private readonly AccountingReportService _report;
|
||||
private readonly AccountingIngestService _ingest;
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
public AccountingWindow(IModuleUiHost uiHost,
|
||||
ILedgerRepository ledger,
|
||||
IIngestRunRepository runs,
|
||||
AccountingReportService report,
|
||||
AccountingIngestService ingest,
|
||||
LoggingService logger)
|
||||
{
|
||||
_ledger = ledger;
|
||||
_runs = runs;
|
||||
_report = report;
|
||||
_ingest = ingest;
|
||||
_logger = logger;
|
||||
|
||||
InitializeComponent();
|
||||
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "accounting.main");
|
||||
|
||||
this.FindControl<DatePicker>("FromDate")!.SelectedDate = DateTimeOffset.Now.AddMonths(-1).Date;
|
||||
this.FindControl<DatePicker>("ToDate")!.SelectedDate = DateTimeOffset.Now.Date;
|
||||
|
||||
var currency = this.FindControl<ComboBox>("CurrencyBox")!;
|
||||
currency.ItemsSource = new[] { "USD", "EUR" };
|
||||
currency.SelectedIndex = 0;
|
||||
|
||||
var account = this.FindControl<ComboBox>("AccountBox")!;
|
||||
account.ItemsSource = new[] { AllAccounts };
|
||||
account.SelectedIndex = 0;
|
||||
|
||||
this.FindControl<Button>("RefreshButton")!.Click += (_, _) => RefreshAll();
|
||||
this.FindControl<Button>("ExportLedgerCsvButton")!.Click += async (_, _) => await ExportLedgerCsvAsync();
|
||||
this.FindControl<Button>("ExportStatementCsvButton")!.Click += async (_, _) => await ExportStatementCsvAsync();
|
||||
this.FindControl<Button>("ExportPdfButton")!.Click += async (_, _) => await ExportPdfAsync();
|
||||
this.FindControl<Button>("IngestIncrementalButton")!.Click += async (_, _) => await RunIngestAsync(backfill: false);
|
||||
this.FindControl<Button>("IngestBackfillButton")!.Click += async (_, _) => await RunIngestAsync(backfill: true);
|
||||
|
||||
this.FindControl<TextBlock>("IngestStatus")!.Text =
|
||||
"Offline-Standard: keine Live-Quelle registriert → der Ingest bucht nichts (korrekt).";
|
||||
|
||||
SetStatus("Bereit – Aktualisieren lädt die Daten.");
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// ── Auswahl ──────────────────────────────────────────────────────────────
|
||||
|
||||
private string? SelectedAccount() =>
|
||||
this.FindControl<ComboBox>("AccountBox")!.SelectedItem as string is { } a && a != AllAccounts ? a : null;
|
||||
|
||||
private string SelectedCurrency() =>
|
||||
(string?)this.FindControl<ComboBox>("CurrencyBox")!.SelectedItem ?? "USD";
|
||||
|
||||
private (DateTime From, DateTime To) SelectedRange()
|
||||
{
|
||||
var from = this.FindControl<DatePicker>("FromDate")!.SelectedDate?.Date ?? DateTime.Today.AddMonths(-1);
|
||||
var to = this.FindControl<DatePicker>("ToDate")!.SelectedDate?.Date ?? DateTime.Today;
|
||||
return (from, to.AddDays(1).AddTicks(-1));
|
||||
}
|
||||
|
||||
// ── Laden ────────────────────────────────────────────────────────────────
|
||||
|
||||
private void RefreshAll()
|
||||
{
|
||||
try
|
||||
{
|
||||
LoadAccounts();
|
||||
LoadOverview();
|
||||
LoadLedger();
|
||||
LoadRuns();
|
||||
SetStatus($"Aktualisiert: {AppTimeZone.Now:HH:mm:ss}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Accounting", $"Aktualisieren fehlgeschlagen: {ex.Message}", ex);
|
||||
SetStatus($"Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadAccounts()
|
||||
{
|
||||
var box = this.FindControl<ComboBox>("AccountBox")!;
|
||||
var current = box.SelectedItem as string;
|
||||
|
||||
var items = new List<string> { AllAccounts };
|
||||
items.AddRange(_ledger.DistinctAccounts());
|
||||
box.ItemsSource = items;
|
||||
box.SelectedItem = current is not null && items.Contains(current) ? current : AllAccounts;
|
||||
}
|
||||
|
||||
private void LoadOverview()
|
||||
{
|
||||
var (from, to) = SelectedRange();
|
||||
var stmt = _report.BuildStatement(SelectedAccount(), from, to);
|
||||
var view = _report.GetCurrencyView(SelectedCurrency(), to);
|
||||
|
||||
string M(decimal v) => (Math.Round(v * view.Factor, 2)).ToString("N2", CultureInfo.InvariantCulture);
|
||||
|
||||
ShowKpis(
|
||||
("Netto-Handelsergebnis", $"{M(stmt.NetTradingResult)} {view.Code}"),
|
||||
("Handelsvolumen", M(stmt.TradeVolume)),
|
||||
("Dividenden", M(stmt.Dividends)),
|
||||
("Fees", M(stmt.Fees)),
|
||||
("Endsaldo", M(stmt.ClosingBalance)),
|
||||
("Trades", stmt.TradeCount.ToString(CultureInfo.InvariantCulture)),
|
||||
("Buchungen", stmt.EntryCount.ToString(CultureInfo.InvariantCulture)));
|
||||
|
||||
this.FindControl<TextBlock>("CurrencyNote")!.Text = view.Note;
|
||||
|
||||
this.FindControl<DataGrid>("MonthlyGrid")!.ItemsSource = _report
|
||||
.BuildMonthly(SelectedAccount(), from, to)
|
||||
.Select(m => new MonthlyRow(
|
||||
m.From.ToString("yyyy-MM", CultureInfo.InvariantCulture),
|
||||
M(m.OpeningBalance), M(m.Deposits), M(m.Withdrawals),
|
||||
M(m.TradeVolume), M(m.Fees), M(m.NetTradingResult), M(m.ClosingBalance)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void LoadLedger()
|
||||
{
|
||||
var (from, to) = SelectedRange();
|
||||
|
||||
this.FindControl<DataGrid>("LedgerGrid")!.ItemsSource = _ledger
|
||||
.Query(SelectedAccount(), from, to, 2000)
|
||||
.Select(e => new LedgerRow(
|
||||
e.Timestamp, e.AccountId, e.EventType.ToString(), e.Side, e.Symbol,
|
||||
e.Currency, e.Quantity, e.PriceNative, e.GrossBase, e.FeeBase, e.NetBase, e.TransactionId))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void LoadRuns()
|
||||
{
|
||||
this.FindControl<DataGrid>("RunsGrid")!.ItemsSource = _runs
|
||||
.GetRecent(SelectedAccount(), 100)
|
||||
.Select(r => new IngestRunRow(
|
||||
r.AccountId, r.StartedAt, r.FinishedAt, r.Backfill,
|
||||
r.NewEntries, r.DuplicateEntries, r.Success,
|
||||
// Nullable: solange kein Saldo-Anker vorliegt, gibt es kein Delta.
|
||||
r.BalanceDeltaBase?.ToString("N2", CultureInfo.InvariantCulture) ?? "–", r.Message))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ── Export ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task ExportLedgerCsvAsync()
|
||||
{
|
||||
var (from, to) = SelectedRange();
|
||||
var entries = _ledger.Query(SelectedAccount(), from, to, 100_000);
|
||||
await SaveTextAsync("ledger.csv", "CSV", "csv", CsvExporter.Ledger(entries));
|
||||
}
|
||||
|
||||
private async Task ExportStatementCsvAsync()
|
||||
{
|
||||
var (from, to) = SelectedRange();
|
||||
var stmt = _report.BuildStatement(SelectedAccount(), from, to);
|
||||
await SaveTextAsync("abrechnung.csv", "CSV", "csv", CsvExporter.Statement(stmt));
|
||||
}
|
||||
|
||||
private async Task ExportPdfAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (from, to) = SelectedRange();
|
||||
var account = SelectedAccount();
|
||||
var stmt = _report.BuildStatement(account, from, to);
|
||||
var monthly = _report.BuildMonthly(account, from, to);
|
||||
var entries = _ledger.Query(account, from, to, 100_000).OrderBy(e => e.Timestamp).ToList();
|
||||
var view = _report.GetCurrencyView(SelectedCurrency(), to);
|
||||
|
||||
var pdf = PdfExporter.Render(stmt, monthly, entries, view.Code, view.Factor, view.Note);
|
||||
|
||||
var file = await PickSaveFileAsync("abrechnung.pdf", "PDF", "pdf");
|
||||
if (file is null) return;
|
||||
|
||||
await using var stream = await file.OpenWriteAsync();
|
||||
await stream.WriteAsync(pdf);
|
||||
|
||||
_logger.Info("Accounting", $"PDF-Abrechnung geschrieben: {file.Name}");
|
||||
SetStatus($"PDF geschrieben: {file.Name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Accounting", $"PDF-Export fehlgeschlagen: {ex.Message}", ex);
|
||||
SetStatus($"PDF-Export fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveTextAsync(string suggested, string typeName, string extension, string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
var file = await PickSaveFileAsync(suggested, typeName, extension);
|
||||
if (file is null) return;
|
||||
|
||||
await using var stream = await file.OpenWriteAsync();
|
||||
await using var writer = new StreamWriter(stream);
|
||||
await writer.WriteAsync(content);
|
||||
|
||||
_logger.Info("Accounting", $"Export geschrieben: {file.Name}");
|
||||
SetStatus($"Export geschrieben: {file.Name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Accounting", $"Export fehlgeschlagen: {ex.Message}", ex);
|
||||
SetStatus($"Export fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Speicherdialog über den Speicheranbieter der Plattform – der Nachfolger von
|
||||
/// <c>SaveFileDialog</c>. Liefert <c>null</c>, wenn der Nutzer abbricht.
|
||||
/// </summary>
|
||||
private async Task<IStorageFile?> PickSaveFileAsync(string suggested, string typeName, string extension) =>
|
||||
await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
SuggestedFileName = suggested,
|
||||
DefaultExtension = extension,
|
||||
FileTypeChoices = [new FilePickerFileType(typeName) { Patterns = [$"*.{extension}"] }]
|
||||
});
|
||||
|
||||
// ── Abruf ────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task RunIngestAsync(bool backfill)
|
||||
{
|
||||
var status = this.FindControl<TextBlock>("IngestStatus")!;
|
||||
try
|
||||
{
|
||||
status.Text = backfill ? "Backfill läuft …" : "Inkrementeller Abruf läuft …";
|
||||
await _ingest.IngestAllAsync(backfill, CancellationToken.None);
|
||||
status.Text = $"Abruf abgeschlossen ({AppTimeZone.Now:HH:mm:ss}).";
|
||||
LoadRuns();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
status.Text = $"Fehler: {ex.Message}";
|
||||
_logger.Error("Accounting", $"Manueller Ingest fehlgeschlagen: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hilfsmittel ──────────────────────────────────────────────────────────
|
||||
|
||||
private void ShowKpis(params (string Caption, string Value)[] kpis)
|
||||
{
|
||||
var panel = this.FindControl<WrapPanel>("KpiPanel")!;
|
||||
panel.Children.Clear();
|
||||
|
||||
foreach (var (caption, value) in kpis)
|
||||
{
|
||||
var stack = new StackPanel();
|
||||
stack.Children.Add(new TextBlock { Text = caption, Classes = { "caption" } });
|
||||
stack.Children.Add(new TextBlock { Text = value, Classes = { "value" } });
|
||||
panel.Children.Add(new Border { Classes = { "kpi" }, Child = stack });
|
||||
}
|
||||
}
|
||||
|
||||
private void SetStatus(string text) =>
|
||||
this.FindControl<TextBlock>("StatusText")!.Text = text;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<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.Modules.CongressTradingWindow"
|
||||
Title="Congress Trading"
|
||||
Width="920" Height="620"
|
||||
MinWidth="640" MinHeight="420"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<DockPanel>
|
||||
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="RefreshButton" Content="Aktualisieren" />
|
||||
<Button x:Name="ScrapeButton" Content="Scrape jetzt"
|
||||
ToolTip.Tip="Löst den CT-Scrape-Worker sofort aus." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock x:Name="StatusText" />
|
||||
</Border>
|
||||
|
||||
<Grid Margin="12" RowDefinitions="Auto,Auto,Auto,*">
|
||||
<TextBlock Grid.Row="0" Text="Congress Trading"
|
||||
FontSize="18" FontWeight="SemiBold" Margin="0,0,0,10" />
|
||||
|
||||
<WrapPanel Grid.Row="1" x:Name="KpiPanel" />
|
||||
|
||||
<TextBlock Grid.Row="2" Classes="section" Text="Offene Positionen (Modul CT)" />
|
||||
|
||||
<dg:DataGrid Grid.Row="3" x:Name="PositionsGrid" AutoGenerateColumns="False"
|
||||
x:DataType="vm:PositionRow">
|
||||
<dg:DataGrid.Columns>
|
||||
<dg:DataGridTextColumn Header="Symbol" Binding="{Binding Symbol}" Width="140" />
|
||||
<dg:DataGridTextColumn Header="Stück" Binding="{Binding Quantity}" Width="100" />
|
||||
<dg:DataGridTextColumn Header="Ø-Kurs" Binding="{Binding AvgPrice}" Width="140" />
|
||||
<dg:DataGridTextColumn Header="Wert" Binding="{Binding Notional}" Width="*" />
|
||||
</dg:DataGrid.Columns>
|
||||
</dg:DataGrid>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,109 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using IBKRTrader.App.Shell;
|
||||
using IBKRTrader.App.ViewModels;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Time;
|
||||
using IBKRTrader.Core.Trading;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using IBKRTrader.Modules.CongressTrading;
|
||||
using IBKRTrader.Modules.CongressTrading.Database;
|
||||
|
||||
namespace IBKRTrader.App.Views.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Fenster des CongressTrading-Moduls: DB-Kennzahlen, manueller Scrape-Auslöser und die offenen
|
||||
/// Positionen des Moduls (aus dem Core-Portfolio).
|
||||
///
|
||||
/// <para>DB-Zugriffe laufen NUR beim Anzeigen und auf Nutzerinteraktion – nie im Konstruktor,
|
||||
/// damit die Konstruktionsprüfung auch ohne Datenbank durchläuft.</para>
|
||||
/// </summary>
|
||||
public partial class CongressTradingWindow : Window
|
||||
{
|
||||
private const string ScrapeWorkerName = "CT-ScrapeWorker";
|
||||
|
||||
private readonly CongressRepository _repo;
|
||||
private readonly WorkerEngine _engine;
|
||||
private readonly IPortfolioService _portfolio;
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
public CongressTradingWindow(IModuleUiHost uiHost,
|
||||
CongressRepository repo,
|
||||
WorkerEngine engine,
|
||||
IPortfolioService portfolio,
|
||||
LoggingService logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_engine = engine;
|
||||
_portfolio = portfolio;
|
||||
_logger = logger;
|
||||
|
||||
InitializeComponent();
|
||||
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "congresstrading.main");
|
||||
|
||||
this.FindControl<Button>("RefreshButton")!.Click += async (_, _) => await RefreshAsync();
|
||||
this.FindControl<Button>("ScrapeButton")!.Click += async (_, _) => await TriggerScrapeAsync();
|
||||
Opened += async (_, _) => await RefreshAsync();
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var trades = await _repo.GetTradeCountAsync();
|
||||
var members = await _repo.GetMemberCountAsync();
|
||||
|
||||
ShowKpis(("Trades in DB", trades.ToString("N0")),
|
||||
("Mitglieder in DB", members.ToString("N0")));
|
||||
|
||||
var positions = await _portfolio.GetPositionsAsync(CongressTradingModule.LogTag);
|
||||
this.FindControl<DataGrid>("PositionsGrid")!.ItemsSource = positions
|
||||
.Select(p => new PositionRow(p.Symbol, p.Quantity, p.AvgPrice, p.Notional))
|
||||
.ToList();
|
||||
|
||||
SetStatus($"Aktualisiert: {AppTimeZone.Now:HH:mm:ss}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowKpis(("Trades in DB", "n/v"), ("Mitglieder in DB", "n/v"));
|
||||
SetStatus($"DB nicht erreichbar: {ex.Message}");
|
||||
_logger.Warn(CongressTradingModule.LogTag, $"Kennzahlen konnten nicht geladen werden: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TriggerScrapeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
SetStatus("Scrape angestoßen …");
|
||||
await _engine.TriggerWorkerAsync(ScrapeWorkerName);
|
||||
_logger.Info(CongressTradingModule.LogTag, "Scrape-Worker manuell ausgelöst (aus Modul-Fenster).");
|
||||
SetStatus("Scrape ausgelöst.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus($"Scrape fehlgeschlagen: {ex.Message}");
|
||||
_logger.Error(CongressTradingModule.LogTag, "Manueller Scrape-Trigger fehlgeschlagen.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowKpis(params (string Caption, string Value)[] kpis)
|
||||
{
|
||||
var panel = this.FindControl<WrapPanel>("KpiPanel")!;
|
||||
panel.Children.Clear();
|
||||
|
||||
foreach (var (caption, value) in kpis)
|
||||
{
|
||||
var stack = new StackPanel();
|
||||
stack.Children.Add(new TextBlock { Text = caption, Classes = { "caption" } });
|
||||
stack.Children.Add(new TextBlock { Text = value, Classes = { "value" } });
|
||||
panel.Children.Add(new Border { Classes = { "kpi" }, Child = stack });
|
||||
}
|
||||
}
|
||||
|
||||
private void SetStatus(string text) =>
|
||||
this.FindControl<TextBlock>("StatusText")!.Text = text;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<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"
|
||||
xmlns:sup="clr-namespace:IBKRTrader.Modules.Supervisor.Services;assembly=IBKRTrader.Modules.Supervisor"
|
||||
x:Class="IBKRTrader.App.Views.Modules.SupervisorWindow"
|
||||
Title="Supervisor"
|
||||
Width="1120" Height="760"
|
||||
MinWidth="820" MinHeight="540"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<DockPanel>
|
||||
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock x:Name="StatusText" />
|
||||
</Border>
|
||||
|
||||
<TabControl>
|
||||
|
||||
<!-- ── Analyse ─────────────────────────────────────────────── -->
|
||||
<TabItem Header="Analyse">
|
||||
<DockPanel Margin="8">
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top" Margin="-8,-8,-8,8">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Profil" VerticalAlignment="Center" Margin="0,0,6,0" />
|
||||
<ComboBox x:Name="ProfileBox" MinWidth="170" />
|
||||
<Button x:Name="AskButton" Content="Fragen" Margin="12,0,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBox x:Name="QuestionBox" DockPanel.Dock="Top"
|
||||
Height="72" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
Watermark="Frage an den Supervisor …" Margin="0,0,0,8" />
|
||||
|
||||
<Border Background="#14141E">
|
||||
<ScrollViewer x:Name="AnswerScroller">
|
||||
<SelectableTextBlock x:Name="AnswerText" Margin="8"
|
||||
Foreground="#D2D2D2"
|
||||
FontFamily="Consolas,Menlo,DejaVu Sans Mono,monospace"
|
||||
FontSize="12" TextWrapping="Wrap" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
|
||||
<!-- ── Dossier-Browser ─────────────────────────────────────── -->
|
||||
<TabItem Header="Dossier-Browser">
|
||||
<Grid ColumnDefinitions="420,4,*">
|
||||
<DockPanel Grid.Column="0">
|
||||
<Button x:Name="LoadSignalsButton" Content="Signale laden"
|
||||
DockPanel.Dock="Top" Margin="6" HorizontalAlignment="Stretch" />
|
||||
<dg:DataGrid x:Name="SignalsGrid" AutoGenerateColumns="False"
|
||||
SelectionMode="Single" x:DataType="sup:SignalSummary">
|
||||
<dg:DataGrid.Columns>
|
||||
<dg:DataGridTextColumn Header="Zeit"
|
||||
Binding="{Binding FirstSeen, StringFormat='{}{0:dd.MM. HH:mm}'}"
|
||||
Width="100" />
|
||||
<dg:DataGridTextColumn Header="Symbol" Binding="{Binding Symbol}" Width="90" />
|
||||
<dg:DataGridTextColumn Header="Modul" Binding="{Binding Module}" Width="110" />
|
||||
<dg:DataGridTextColumn Header="Entscheid." Binding="{Binding LastDecision}" Width="*" />
|
||||
</dg:DataGrid.Columns>
|
||||
</dg:DataGrid>
|
||||
</DockPanel>
|
||||
|
||||
<GridSplitter Grid.Column="1" Background="#DDDDDD" />
|
||||
|
||||
<Border Grid.Column="2" Background="#14141E">
|
||||
<ScrollViewer>
|
||||
<SelectableTextBlock x:Name="DossierText" Margin="8"
|
||||
Foreground="#D2D2D2"
|
||||
FontFamily="Consolas,Menlo,DejaVu Sans Mono,monospace"
|
||||
FontSize="12" TextWrapping="Wrap"
|
||||
Text="Signal links auswählen." />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- ── Berichte ────────────────────────────────────────────── -->
|
||||
<TabItem Header="Berichte">
|
||||
<DockPanel>
|
||||
<Button x:Name="LoadReportsButton" Content="Berichte laden"
|
||||
DockPanel.Dock="Top" Margin="6" HorizontalAlignment="Left" />
|
||||
<dg:DataGrid x:Name="ReportsGrid" AutoGenerateColumns="False"
|
||||
x:DataType="vm:SupervisorReportRow">
|
||||
<dg:DataGrid.Columns>
|
||||
<dg:DataGridTextColumn Header="Erstellt"
|
||||
Binding="{Binding CreatedAt, StringFormat='{}{0:dd.MM.yyyy HH:mm}'}"
|
||||
Width="140" />
|
||||
<dg:DataGridTextColumn Header="Profil" Binding="{Binding Profile}" Width="110" />
|
||||
<dg:DataGridTextColumn Header="Modell" Binding="{Binding Model}" Width="180" />
|
||||
<dg:DataGridTextColumn Header="Frage" Binding="{Binding Question}" Width="*" />
|
||||
<dg:DataGridTextColumn Header="Tools" Binding="{Binding ToolCallCount}" Width="70" />
|
||||
<dg:DataGridTextColumn Header="Tokens" Binding="{Binding Tokens}" Width="110" />
|
||||
</dg:DataGrid.Columns>
|
||||
</dg:DataGrid>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
|
||||
<!-- ── Hinweise ────────────────────────────────────────────── -->
|
||||
<TabItem Header="Hinweise">
|
||||
<ScrollViewer>
|
||||
<SelectableTextBlock x:Name="InfoText" Margin="16" TextWrapping="Wrap" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,197 @@
|
||||
using System.Text.Json;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using IBKRTrader.App.Shell;
|
||||
using IBKRTrader.App.ViewModels;
|
||||
using IBKRTrader.Core.Analytics;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Time;
|
||||
using IBKRTrader.Modules.Supervisor.Agent;
|
||||
using IBKRTrader.Modules.Supervisor.Persistence;
|
||||
using IBKRTrader.Modules.Supervisor.Services;
|
||||
|
||||
namespace IBKRTrader.App.Views.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Fenster des Supervisor-Moduls: Analyse (Chat mit dem Agenten, Tool-Aufrufe sichtbar),
|
||||
/// Dossier-Browser, Berichte und Hinweise. Strikt read-only – kein Tool kann handeln oder schreiben.
|
||||
///
|
||||
/// <para>DB- und Agent-Zugriffe laufen NUR auf Nutzerinteraktion, nie im Konstruktor.</para>
|
||||
/// </summary>
|
||||
public partial class SupervisorWindow : Window
|
||||
{
|
||||
private readonly SupervisorAgent _agent;
|
||||
private readonly DossierService _dossiers;
|
||||
private readonly ISupervisorReportRepository _reports;
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
public SupervisorWindow(IModuleUiHost uiHost,
|
||||
SupervisorAgent agent,
|
||||
DossierService dossiers,
|
||||
ISupervisorReportRepository reports,
|
||||
LoggingService logger)
|
||||
{
|
||||
_agent = agent;
|
||||
_dossiers = dossiers;
|
||||
_reports = reports;
|
||||
_logger = logger;
|
||||
|
||||
InitializeComponent();
|
||||
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "supervisor.main");
|
||||
|
||||
var profiles = this.FindControl<ComboBox>("ProfileBox")!;
|
||||
profiles.ItemsSource = SupervisorProfiles.All.Select(p => p.Name).ToList();
|
||||
profiles.SelectedIndex = 0;
|
||||
|
||||
this.FindControl<Button>("AskButton")!.Click += async (_, _) => await AskAsync();
|
||||
this.FindControl<Button>("LoadSignalsButton")!.Click += (_, _) => LoadSignals();
|
||||
this.FindControl<Button>("LoadReportsButton")!.Click += (_, _) => LoadReports();
|
||||
this.FindControl<DataGrid>("SignalsGrid")!.SelectionChanged += (_, _) => ShowSelectedDossier();
|
||||
|
||||
this.FindControl<SelectableTextBlock>("InfoText")!.Text = BuildInfoText();
|
||||
|
||||
SetStatus("Bereit.");
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// ── Analyse ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task AskAsync()
|
||||
{
|
||||
var questionBox = this.FindControl<TextBox>("QuestionBox")!;
|
||||
var question = (questionBox.Text ?? "").Trim();
|
||||
if (question.Length == 0) return;
|
||||
|
||||
var askButton = this.FindControl<Button>("AskButton")!;
|
||||
var answer = this.FindControl<SelectableTextBlock>("AnswerText")!;
|
||||
|
||||
askButton.IsEnabled = false;
|
||||
answer.Text = "";
|
||||
|
||||
var profile = SupervisorProfiles.ByName((string?)this.FindControl<ComboBox>("ProfileBox")!.SelectedItem ?? "");
|
||||
|
||||
// Der Agent meldet Tool-Aufrufe im Verlauf – die sollen live sichtbar sein, nicht erst
|
||||
// am Ende. Progress<T> meldet auf dem erfassten Kontext; der Dispatcher-Wechsel bleibt
|
||||
// trotzdem stehen, weil der Agent aus einem Worker-Thread berichten kann.
|
||||
var progress = new Progress<string>(AppendLine);
|
||||
|
||||
try
|
||||
{
|
||||
SetStatus("Analyse läuft …");
|
||||
var result = await _agent.AskAsync(question, profile: profile, progress: progress);
|
||||
|
||||
AppendLine("");
|
||||
AppendLine("─── Antwort ───");
|
||||
AppendLine(result.Answer);
|
||||
|
||||
_reports.Insert(new SupervisorReport
|
||||
{
|
||||
Profile = profile.Name,
|
||||
Model = SupervisorAgent.DefaultModel,
|
||||
Question = question,
|
||||
Answer = result.Answer,
|
||||
ToolCallsJson = JsonSerializer.Serialize(result.ToolInvocations.Select(i => new { i.Tool, i.Arguments })),
|
||||
ToolCallCount = result.ToolInvocations.Count,
|
||||
PromptTokens = result.PromptTokens,
|
||||
CompletionTokens = result.CompletionTokens
|
||||
});
|
||||
|
||||
SetStatus($"Analyse abgeschlossen ({result.ToolInvocations.Count} Tool-Aufrufe).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppendLine("");
|
||||
AppendLine($"FEHLER: {ex.Message}");
|
||||
_logger.Warn("Supervisor", $"Analyse fehlgeschlagen: {ex.Message}");
|
||||
SetStatus($"Analyse fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
askButton.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void AppendLine(string text)
|
||||
{
|
||||
if (!Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => AppendLine(text));
|
||||
return;
|
||||
}
|
||||
|
||||
var block = this.FindControl<SelectableTextBlock>("AnswerText")!;
|
||||
block.Text += text + Environment.NewLine;
|
||||
this.FindControl<ScrollViewer>("AnswerScroller")!.ScrollToEnd();
|
||||
}
|
||||
|
||||
// ── Dossier ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void LoadSignals()
|
||||
{
|
||||
try
|
||||
{
|
||||
var signals = _dossiers.RecentSignals(200);
|
||||
this.FindControl<DataGrid>("SignalsGrid")!.ItemsSource = signals;
|
||||
SetStatus($"{signals.Count} Signale geladen.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn("Supervisor", $"Signale laden fehlgeschlagen: {ex.Message}");
|
||||
SetStatus($"Signale laden fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowSelectedDossier()
|
||||
{
|
||||
if (this.FindControl<DataGrid>("SignalsGrid")!.SelectedItem is not SignalSummary s) return;
|
||||
|
||||
var target = this.FindControl<SelectableTextBlock>("DossierText")!;
|
||||
try { target.Text = DossierBuilder.ToMarkdown(_dossiers.BuildForSignal(s.SignalId)); }
|
||||
catch (Exception ex) { target.Text = $"FEHLER: {ex.Message}"; }
|
||||
}
|
||||
|
||||
// ── Berichte ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void LoadReports()
|
||||
{
|
||||
try
|
||||
{
|
||||
var rows = _reports.GetRecent(100)
|
||||
.Select(r => new SupervisorReportRow(
|
||||
r.CreatedAt, r.Profile, r.Model, r.Question, r.ToolCallCount,
|
||||
$"{r.PromptTokens} / {r.CompletionTokens}"))
|
||||
.ToList();
|
||||
|
||||
this.FindControl<DataGrid>("ReportsGrid")!.ItemsSource = rows;
|
||||
SetStatus($"{rows.Count} Berichte geladen.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn("Supervisor", $"Berichte laden fehlgeschlagen: {ex.Message}");
|
||||
SetStatus($"Berichte laden fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hinweise ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static string BuildInfoText()
|
||||
{
|
||||
var keySet = !string.IsNullOrEmpty(OpenRouterClient.DefaultApiKeyProvider());
|
||||
|
||||
return
|
||||
"Supervisor – read-only Analyse und Forensik über alle Module." + Environment.NewLine + Environment.NewLine +
|
||||
"OpenRouter-Key: env IBKRTRADER_OPENROUTER_KEY oder Datei 'openrouter.key' (gitignored)." + Environment.NewLine +
|
||||
$" Status: {(keySet ? "gesetzt" : "NICHT gesetzt – Analyse nicht verfügbar")}" + Environment.NewLine + Environment.NewLine +
|
||||
"Tagesbericht (opt-in): env IBKRTRADER_SUPERVISOR_DAILY = Stunde 0–23." + Environment.NewLine +
|
||||
$" Die Stunde gilt in der Betriebszeitzone dieser Instanz ({AppTimeZone.CurrentId})." + Environment.NewLine +
|
||||
"MCP-Light (opt-in): env IBKRTRADER_MCP_PORT = Port (bindet nur 127.0.0.1)." + Environment.NewLine + Environment.NewLine +
|
||||
"Sicherheit: OpenRouter ist ein bewusst freigegebener externer Datenempfänger. Gesendet werden " +
|
||||
"nur Analyse-Daten der Tools, niemals Secrets. Kein Tool kann handeln oder schreiben.";
|
||||
}
|
||||
|
||||
private void SetStatus(string text) =>
|
||||
this.FindControl<TextBlock>("StatusText")!.Text = $"{text} | {AppTimeZone.Now:HH:mm:ss}";
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="IBKRTrader.App.Views.SettingsWindow"
|
||||
Title="Settings"
|
||||
Width="860" Height="740"
|
||||
MinWidth="620" MinHeight="420"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<DockPanel>
|
||||
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="SaveButton" Content="Speichern" />
|
||||
<Button x:Name="ReloadButton" Content="Verwerfen"
|
||||
ToolTip.Tip="Lädt die gespeicherten Werte neu und verwirft ungespeicherte Änderungen." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock x:Name="StatusText" />
|
||||
</Border>
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel x:Name="SectionPanel" Margin="14" Spacing="4" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using IBKRTrader.App.Shell;
|
||||
using IBKRTrader.App.ViewModels;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Core.Time;
|
||||
|
||||
namespace IBKRTrader.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Ansicht: Einstellungen. Ersetzt das <c>PropertyGrid</c> der WinForms-Fassung durch eine
|
||||
/// aus den Attributen erzeugte Maske (siehe <see cref="SettingsModelBuilder"/>).
|
||||
///
|
||||
/// <para>Geändert wird direkt auf dem <c>AppSettings</c>-Objekt; „Speichern" schreibt es nach
|
||||
/// <c>settings.json</c>, „Verwerfen" lädt die Datei neu. Zahlen werden ausdrücklich gegen
|
||||
/// <see cref="CultureInfo.InvariantCulture"/> gelesen – die Datei ist maschinenlesbar und darf
|
||||
/// nicht von der Kultur des Rechners abhängen.</para>
|
||||
/// </summary>
|
||||
public partial class SettingsWindow : Window
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public SettingsWindow(IModuleUiHost uiHost, SettingsService settings)
|
||||
{
|
||||
_settings = settings;
|
||||
|
||||
InitializeComponent();
|
||||
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "core.settings");
|
||||
|
||||
this.FindControl<Button>("SaveButton")!.Click += (_, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_settings.Save();
|
||||
SetStatus("Gespeichert. Zeitzonen-Änderungen greifen erst nach einem Neustart.");
|
||||
}
|
||||
catch (Exception ex) { SetStatus($"Speichern fehlgeschlagen: {ex.Message}"); }
|
||||
};
|
||||
|
||||
this.FindControl<Button>("ReloadButton")!.Click += (_, _) =>
|
||||
{
|
||||
_settings.Load();
|
||||
BuildForm();
|
||||
SetStatus("Gespeicherte Werte neu geladen.");
|
||||
};
|
||||
|
||||
BuildForm();
|
||||
SetStatus("Bereit.");
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
private void BuildForm()
|
||||
{
|
||||
var panel = this.FindControl<StackPanel>("SectionPanel")!;
|
||||
panel.Children.Clear();
|
||||
|
||||
foreach (var section in SettingsModelBuilder.Build(_settings.Settings))
|
||||
{
|
||||
var grid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("240,*"),
|
||||
Margin = new Thickness(4, 4, 4, 12)
|
||||
};
|
||||
|
||||
for (var i = 0; i < section.Fields.Count; i++)
|
||||
{
|
||||
var field = section.Fields[i];
|
||||
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
|
||||
var label = new TextBlock
|
||||
{
|
||||
Text = field.DisplayName,
|
||||
Margin = new Thickness(0, 6, 10, 6),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(field.Description))
|
||||
ToolTip.SetTip(label, field.Description);
|
||||
|
||||
var editor = CreateEditor(field);
|
||||
editor.Margin = new Thickness(0, 4, 0, 4);
|
||||
if (!string.IsNullOrWhiteSpace(field.Description))
|
||||
ToolTip.SetTip(editor, field.Description);
|
||||
|
||||
Grid.SetRow(label, i); Grid.SetColumn(label, 0);
|
||||
Grid.SetRow(editor, i); Grid.SetColumn(editor, 1);
|
||||
grid.Children.Add(label);
|
||||
grid.Children.Add(editor);
|
||||
}
|
||||
|
||||
panel.Children.Add(new Expander
|
||||
{
|
||||
Header = section.Title,
|
||||
IsExpanded = true,
|
||||
Content = grid,
|
||||
Margin = new Thickness(0, 0, 0, 6),
|
||||
HorizontalContentAlignment = HorizontalAlignment.Stretch
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wählt das Eingabeelement passend zum Typ des Feldes.</summary>
|
||||
private static Control CreateEditor(SettingsField field)
|
||||
{
|
||||
if (field.ValueType == typeof(bool))
|
||||
{
|
||||
var check = new CheckBox { IsChecked = (bool?)field.Get() };
|
||||
check.IsCheckedChanged += (_, _) => field.Set(check.IsChecked == true);
|
||||
return check;
|
||||
}
|
||||
|
||||
if (field.ValueType.IsEnum)
|
||||
{
|
||||
var combo = new ComboBox
|
||||
{
|
||||
ItemsSource = Enum.GetValues(field.ValueType),
|
||||
SelectedItem = field.Get(),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
MinWidth = 200
|
||||
};
|
||||
combo.SelectionChanged += (_, _) => { if (combo.SelectedItem is not null) field.Set(combo.SelectedItem); };
|
||||
return combo;
|
||||
}
|
||||
|
||||
if (field.ValueType == typeof(int) || field.ValueType == typeof(long) ||
|
||||
field.ValueType == typeof(double) || field.ValueType == typeof(decimal))
|
||||
{
|
||||
var numeric = new NumericUpDown
|
||||
{
|
||||
Value = ToDecimal(field.Get()),
|
||||
Increment = field.ValueType == typeof(double) || field.ValueType == typeof(decimal) ? 0.5m : 1m,
|
||||
FormatString = field.ValueType == typeof(double) || field.ValueType == typeof(decimal) ? "0.###" : "0",
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
MinWidth = 200
|
||||
};
|
||||
numeric.ValueChanged += (_, _) =>
|
||||
{
|
||||
if (numeric.Value is not { } v) return;
|
||||
field.Set(Convert.ChangeType(v, field.ValueType, CultureInfo.InvariantCulture));
|
||||
};
|
||||
return numeric;
|
||||
}
|
||||
|
||||
var box = new TextBox { Text = field.Get()?.ToString() ?? "" };
|
||||
// Kennwortfelder (DB-Passwort, Flex-Token) nicht im Klartext anzeigen.
|
||||
if (field.IsPassword) box.PasswordChar = '•';
|
||||
box.TextChanged += (_, _) => field.Set(box.Text ?? "");
|
||||
return box;
|
||||
}
|
||||
|
||||
private static decimal ToDecimal(object? value) =>
|
||||
value is null ? 0m : Convert.ToDecimal(value, CultureInfo.InvariantCulture);
|
||||
|
||||
private void SetStatus(string text) =>
|
||||
this.FindControl<TextBlock>("StatusText")!.Text = $"{text} | {AppTimeZone.Now:HH:mm:ss}";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="IBKRTrader.App.Views.ShutdownConfirmWindow"
|
||||
Title="Beenden"
|
||||
Width="440" SizeToContent="Height"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<StackPanel Margin="20" Spacing="14">
|
||||
<TextBlock Text="IBKRTrader wirklich beenden?"
|
||||
FontSize="15" FontWeight="SemiBold" />
|
||||
|
||||
<TextBlock TextWrapping="Wrap" Foreground="#555555"
|
||||
Text="Laufende Worker und Dienste werden gestoppt. Offene Broker-Anfragen werden abgebrochen; bereits platzierte Orders bleiben beim Broker bestehen und werden NICHT storniert." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||
<Button x:Name="CancelButton" Content="Abbrechen" IsCancel="True" MinWidth="100" />
|
||||
<Button x:Name="ConfirmButton" Content="Beenden" IsDefault="True" MinWidth="100" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,31 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace IBKRTrader.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Sicherheitsabfrage vor dem Beenden. Ersetzt <c>MessageBox.Show</c> – Avalonia bringt keinen
|
||||
/// eingebauten Meldungsdialog mit.
|
||||
///
|
||||
/// <para>Liefert <c>true</c> bei Bestätigung, sonst <c>false</c>; auch das Schließen über das X
|
||||
/// zählt als Abbruch, damit ein versehentlicher Klick nie den Handelsbetrieb stoppt.</para>
|
||||
/// </summary>
|
||||
public partial class ShutdownConfirmWindow : Window
|
||||
{
|
||||
public ShutdownConfirmWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
this.FindControl<Button>("ConfirmButton")!.Click += (_, _) => Close(true);
|
||||
this.FindControl<Button>("CancelButton")!.Click += (_, _) => Close(false);
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
protected override void OnClosing(WindowClosingEventArgs e)
|
||||
{
|
||||
// Wird das Fenster über das X geschlossen, ist kein Ergebnis gesetzt – ShowDialog<bool>
|
||||
// liefert dann default(bool) = false. Genau das ist gewollt.
|
||||
base.OnClosing(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<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:w="clr-namespace:IBKRTrader.Core.Workers;assembly=IBKRTrader.Core"
|
||||
x:Class="IBKRTrader.App.Views.WorkersWindow"
|
||||
Title="Workers / Services"
|
||||
Width="1200" Height="700"
|
||||
MinWidth="760" MinHeight="420"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<DockPanel>
|
||||
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="TriggerButton" Content="Jetzt ausführen"
|
||||
ToolTip.Tip="Löst den ausgewählten Worker sofort aus." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock x:Name="StatusText" />
|
||||
</Border>
|
||||
|
||||
<!-- Die Zeilen sind WorkerInfo-Objekte aus der WorkerEngine. Sie melden Änderungen über
|
||||
INotifyPropertyChanged, deshalb aktualisiert sich das Raster von selbst. -->
|
||||
<dg:DataGrid x:Name="WorkersGrid" AutoGenerateColumns="False" SelectionMode="Single"
|
||||
x:DataType="w:WorkerInfo">
|
||||
<dg:DataGrid.Columns>
|
||||
<dg:DataGridCheckBoxColumn Header="Aktiv" Binding="{Binding Active}" Width="60" />
|
||||
<dg:DataGridTextColumn Header="Typ" Binding="{Binding Type}" Width="80" />
|
||||
<dg:DataGridTextColumn Header="Modul" Binding="{Binding Module}" Width="100" />
|
||||
<dg:DataGridTextColumn Header="Worker" Binding="{Binding WorkerName}" Width="200" />
|
||||
<dg:DataGridTextColumn Header="Letzter Lauf"
|
||||
Binding="{Binding LastRuntime, StringFormat='{}{0:dd.MM.yyyy HH:mm:ss}', TargetNullValue='–'}"
|
||||
Width="150" />
|
||||
<dg:DataGridTextColumn Header="Nächster Lauf"
|
||||
Binding="{Binding NextRuntime, StringFormat='{}{0:dd.MM.yyyy HH:mm:ss}', TargetNullValue='–'}"
|
||||
Width="150" />
|
||||
<dg:DataGridTextColumn Header="Intervall" Binding="{Binding RunEvery}" Width="90" />
|
||||
<dg:DataGridTextColumn Header="Info" Binding="{Binding Info}" Width="*" />
|
||||
</dg:DataGrid.Columns>
|
||||
</dg:DataGrid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,61 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using IBKRTrader.App.Shell;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Time;
|
||||
using IBKRTrader.Core.Workers;
|
||||
|
||||
namespace IBKRTrader.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Ansicht: Worker- und Service-Übersicht, live an die <see cref="WorkerEngine"/> gebunden.
|
||||
///
|
||||
/// <para>Die Liste selbst ändert sich zur Laufzeit nicht – die Worker werden einmal im
|
||||
/// Konstruktor der Engine registriert. Was sich ändert, sind die Eigenschaften je Zeile, und
|
||||
/// die meldet <c>WorkerInfo</c> über <c>INotifyPropertyChanged</c>. Deshalb genügt hier die
|
||||
/// direkte Bindung an die Liste der Engine, ohne eine gespiegelte Sammlung.</para>
|
||||
/// </summary>
|
||||
public partial class WorkersWindow : Window
|
||||
{
|
||||
private readonly WorkerEngine _engine;
|
||||
|
||||
public WorkersWindow(IModuleUiHost uiHost, WorkerEngine engine)
|
||||
{
|
||||
_engine = engine;
|
||||
|
||||
InitializeComponent();
|
||||
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "core.workers");
|
||||
|
||||
var grid = this.FindControl<DataGrid>("WorkersGrid")!;
|
||||
grid.ItemsSource = _engine.WorkerInfos;
|
||||
|
||||
this.FindControl<Button>("TriggerButton")!.Click += async (_, _) => await TriggerSelectedAsync();
|
||||
|
||||
SetStatus($"{_engine.WorkerInfos.Count} Worker/Services registriert.");
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
private async Task TriggerSelectedAsync()
|
||||
{
|
||||
if (this.FindControl<DataGrid>("WorkersGrid")!.SelectedItem is not WorkerInfo selected)
|
||||
{
|
||||
SetStatus("Kein Worker ausgewählt.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SetStatus($"{selected.WorkerName} wird ausgelöst …");
|
||||
await _engine.TriggerWorkerAsync(selected.WorkerName);
|
||||
SetStatus($"{selected.WorkerName} ausgelöst.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus($"{selected.WorkerName} 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