L6: IBKRTrader.App.Avalonia -> IBKRTrader.App; letzte WinForms-Spuren raus
Build & Test / build (ubuntu-latest) (push) Waiting to run
Build & Test / build (windows-latest) (push) Waiting to run

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:
Richard
2026-08-07 21:16:48 +02:00
co-authored by Claude Opus 5
parent d86a083438
commit c176b05ea1
45 changed files with 139 additions and 98 deletions
+74
View File
@@ -0,0 +1,74 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="IBKRTrader.App.App"
RequestedThemeVariant="Light">
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
<!-- Projektweite Grundgestaltung. Bewusst hier zentral statt je Fenster wiederholt, damit
alle Fenster gleich aussehen und die neuen Module direkt darauf aufsetzen können. -->
<Style Selector="DataGrid">
<Setter Property="GridLinesVisibility" Value="Horizontal" />
<Setter Property="HeadersVisibility" Value="Column" />
<Setter Property="IsReadOnly" Value="True" />
<Setter Property="CanUserResizeColumns" Value="True" />
<Setter Property="CanUserSortColumns" Value="True" />
<Setter Property="RowHeight" Value="24" />
<Setter Property="FontSize" Value="12" />
</Style>
<!-- KPI-Kachel: Ersatz für die zweizeiligen Labels der WinForms-Oberfläche. -->
<Style Selector="Border.kpi">
<Setter Property="Background" Value="#F5F5F5" />
<Setter Property="BorderBrush" Value="#DDDDDD" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="Margin" Value="0,0,8,8" />
<Setter Property="MinWidth" Value="150" />
</Style>
<Style Selector="Border.kpi TextBlock.caption">
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="#666666" />
</Style>
<Style Selector="Border.kpi TextBlock.value">
<Setter Property="FontSize" Value="18" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!-- Werkzeugleiste oben in jedem Fenster (Ersatz für ToolStrip). -->
<Style Selector="Border.toolbar">
<Setter Property="Background" Value="#FAFAFA" />
<Setter Property="BorderBrush" Value="#DDDDDD" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="Padding" Value="6,4" />
</Style>
<Style Selector="Border.toolbar Button">
<Setter Property="Margin" Value="0,0,6,0" />
<Setter Property="Padding" Value="10,4" />
</Style>
<!-- Statusleiste unten (Ersatz für StatusStrip). -->
<Style Selector="Border.statusbar">
<Setter Property="Background" Value="#FAFAFA" />
<Setter Property="BorderBrush" Value="#DDDDDD" />
<Setter Property="BorderThickness" Value="0,1,0,0" />
<Setter Property="Padding" Value="8,4" />
</Style>
<Style Selector="Border.statusbar TextBlock">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="#666666" />
</Style>
<!-- Abschnittsüberschrift innerhalb eines Fensters. -->
<Style Selector="TextBlock.section">
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,8,0,4" />
</Style>
</Application.Styles>
</Application>
+50
View File
@@ -0,0 +1,50 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using IBKRTrader.App.Shell;
using IBKRTrader.App.Views;
using IBKRTrader.Core.Modularity;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.App;
/// <summary>
/// Avalonia-Anwendungsobjekt. Verbindet den bereits laufenden Generic Host (Trading-Dienste,
/// Module, Persistenz) mit der Oberfläche: beim Start wird der Launcher erzeugt und die Views von
/// Core und Modulen werden bei der Shell registriert.
/// </summary>
public partial class App : Application
{
/// <summary>
/// Wird von <see cref="Program"/> vor <c>StartWithClassicDesktopLifetime</c> gesetzt.
/// Bewusst statisch: Avalonia erzeugt die Application-Instanz selbst, ein Konstruktorparameter
/// ist deshalb nicht möglich.
/// </summary>
public static IServiceProvider? Services { get; set; }
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && Services != null)
{
var uiHost = Services.GetRequiredService<AvaloniaUiHost>();
CoreViews.Register(uiHost, Services);
foreach (var module in Services.GetServices<IModule>())
module.RegisterUi(uiHost, Services);
ModuleViews.Register(uiHost, Services);
ViewIcons.AssignDefaults(uiHost);
var launcher = new LauncherWindow(uiHost, Services);
uiHost.SetMainWindow(launcher);
desktop.MainWindow = launcher;
// Das Schließen des Launchers läuft über die Sicherheitsabfrage (wie bisher das X).
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
}
base.OnFrameworkInitializationCompleted();
}
}
+56
View File
@@ -0,0 +1,56 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Plattformneutrale Oberfläche: dieselbe Anwendung läuft unter Windows und Linux.
WinExe unterdrückt nur das Konsolenfenster unter Windows mit WinForms hat das nichts
zu tun. Der letzte Stand vor der Portierung liegt im Git-Tag winforms-final. -->
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>IBKRTrader.App</AssemblyName>
<RootNamespace>IBKRTrader.App</RootNamespace>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<!-- Layout wird deklarativ in .axaml gebaut, nicht zur Laufzeit im Code. Kompilierte Bindings
verlangen ein x:DataType je Datenkontext dafür fallen Tippfehler in Bindings beim
Kompilieren auf statt erst zur Laufzeit. -->
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- AVLN3001: Die Fenster haben bewusst KEINEN parameterlosen Konstruktor sie bekommen ihre
Dienste per DI. Damit sind sie für den XAML-Previewer nicht ladbar, was wir hinnehmen:
ein parameterloser Konstruktor würde ein Fenster ohne seine Dienste konstruierbar machen
und genau den Fehler verdecken, den die Konstruktionsprüfung finden soll. -->
<NoWarn>$(NoWarn);AVLN3001</NoWarn>
<!-- Wie beim Daemon: ICU ist Pflicht. Ohne sie kippt die feste de-DE-Formatierung des
PDF-Exports auf invariant und Windows-Zeitzonen-IDs lassen sich nicht mehr auflösen. -->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<!-- BEWUSST die 11er-Linie, nicht 12.x. LiveCharts2 (2.0.5, aktuellste Fassung) ist gegen
Avalonia 11 gebaut und bricht unter 12: Avalonia.Input.Gestures.PinchEvent gibt es dort
nicht mehr. Diagramme kommen mit den neuen Modulen erst wenn LiveCharts2 Avalonia 12
unterstützt, darf hier angehoben werden. Erfahrung aus PolytraderSharp. -->
<PackageReference Include="Avalonia" Version="11.3.19" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.19" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.19" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.19" />
<!-- DataGrid folgt einer eigenen Versionsreihe und endet in der 11er-Linie bei 11.3.13. -->
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.13" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IBKRTrader.Hosting\IBKRTrader.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Die Symbole der bisherigen Oberfläche unverändert weiterverwenden (siehe Shell/ViewIcons.cs). -->
<AvaloniaResource Include="..\..\Resources\*.png" Link="Assets\%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.json')" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+81
View File
@@ -0,0 +1,81 @@
using Avalonia;
using IBKRTrader.App.Shell;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace IBKRTrader.App;
/// <summary>
/// Einstiegspunkt der plattformneutralen Oberfläche.
///
/// <para>Bewusst zweigeteilt: <see cref="AppHostBuilder"/> stellt den Host mit Persistenz,
/// Diensten und Modulen zusammen ohne jeden Bezug zur Oberfläche. Erst danach wird Avalonia
/// daran gehängt. Derselbe Host trägt den kopflosen Linux-Dienst
/// (<c>IBKRTrader.Daemon</c>).</para>
/// </summary>
internal static class Program
{
[STAThread]
public static int Main(string[] args)
{
// Konstruktionsprüfung aller Fenster ohne Message-Loop und ohne laufende Dienste.
if (HasFlag(args, "--smoke-ui")) return SmokeUi.Run();
var modules = AppHostBuilder.CreateModules();
using var host = AppHostBuilder.Build(modules, ShellServices.Register);
AppHostBuilder.RunStartupChecks(host.Services);
var logger = host.Services.GetRequiredService<LoggingService>();
logger.Info("Core", "=== IBKRTrader startet ===");
logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}");
host.Start();
StartModules(modules, logger);
try
{
App.Services = host.Services;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return 0;
}
finally
{
// Geordnetes Herunterfahren erst nachdem die Oberfläche beendet ist.
StopModules(modules, logger);
host.StopAsync(TimeSpan.FromSeconds(30)).GetAwaiter().GetResult();
}
}
/// <summary>Von Avalonia erwartete Fabrikmethode (auch vom XAML-Previewer genutzt).</summary>
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
private static void StartModules(IReadOnlyList<IModule> modules, LoggingService logger)
{
foreach (var module in modules)
{
try { module.StartAsync(default).GetAwaiter().GetResult(); }
catch (Exception ex) { logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
}
logger.Info("Core", "IBKRTrader bereit.");
}
private static void StopModules(IReadOnlyList<IModule> modules, LoggingService logger)
{
foreach (var module in modules)
{
try { module.StopAsync(default).GetAwaiter().GetResult(); }
catch (Exception ex) { logger.Warn(module.Name, $"{module.Name}: Stopp fehlgeschlagen: {ex.Message}"); }
}
}
private static bool HasFlag(string[] args, string flag) =>
args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase));
}
+120
View File
@@ -0,0 +1,120 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using IBKRTrader.App.Views;
using IBKRTrader.Core.Modularity;
namespace IBKRTrader.App.Shell;
/// <summary>
/// Avalonia-Umsetzung von <see cref="IModuleUiHost"/>. Verhält sich wie der frühere
/// WinForms-<c>ShellUiHost</c>: je View höchstens ein Fenster, ein bereits offenes wird nach vorn
/// geholt, der Offen-Status wird gemeldet (für die Markierung im Launcher).
///
/// <para>Der Core-Contract ist toolkit-neutral <see cref="ModuleView.CreateView"/> liefert
/// <see cref="object"/>. Hier wird auf <see cref="Window"/> gecastet: ein anderer Typ ist ein
/// Programmierfehler und soll laut scheitern, nicht still ein leeres Fenster ergeben.</para>
/// </summary>
public sealed class AvaloniaUiHost : IModuleUiHost
{
private readonly List<ModuleView> _views = [];
private readonly Dictionary<string, Window> _open = new(StringComparer.OrdinalIgnoreCase);
private Window? _mainWindow;
private bool _shutdownDialogOpen;
/// <summary>True, sobald das Herunterfahren über die Sicherheitsabfrage bestätigt wurde.</summary>
public bool ShutdownConfirmed { get; private set; }
public event Action? OpenStateChanged;
public IReadOnlyList<ModuleView> Views => _views;
public void RegisterView(ModuleView view) => _views.Add(view);
/// <summary>Setzt das Hauptfenster (Launcher) Ziel für <see cref="ActivateMain"/>.</summary>
public void SetMainWindow(Window main) => _mainWindow = main;
public bool IsOpen(string viewId) => _open.ContainsKey(viewId);
public void ActivateMain()
{
if (_mainWindow is null) return;
if (_mainWindow.WindowState == WindowState.Minimized)
_mainWindow.WindowState = WindowState.Normal;
_mainWindow.Activate();
}
public void OpenView(string viewId)
{
var view = _views.FirstOrDefault(v => v.Id == viewId);
if (view is not null) OpenView(view);
}
private void OpenView(ModuleView view)
{
if (_open.TryGetValue(view.Id, out var existing))
{
if (existing.WindowState == WindowState.Minimized)
existing.WindowState = WindowState.Normal;
existing.Activate();
return;
}
var window = (Window)view.CreateView();
if (string.IsNullOrEmpty(window.Title)) window.Title = view.Title;
window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
_open[view.Id] = window;
window.Closed += (_, _) =>
{
_open.Remove(view.Id);
OpenStateChanged?.Invoke();
};
window.Show();
OpenStateChanged?.Invoke();
}
/// <summary>
/// Zeigt die Sicherheitsabfrage und fährt bei Bestätigung herunter. Aus jedem Fenster
/// aufrufbar auch aus Modul-Fenstern, die nur den Core-Contract kennen.
///
/// <para><c>async void</c> ist hier korrekt: die Methode ist ein Ereignis-Handler hinter dem
/// synchronen Contract <see cref="IModuleUiHost.RequestShutdown"/>, und der Dialog muss
/// erwartet werden. Ausnahmen können nicht entweichen der Dialog wirft nicht, und der
/// <c>finally</c>-Block gibt die Sperre in jedem Fall frei.</para>
/// </summary>
public async void RequestShutdown()
{
if (ShutdownConfirmed || _shutdownDialogOpen) return;
_shutdownDialogOpen = true;
try
{
var owner = _mainWindow;
if (owner is null) return;
var confirmed = await new ShutdownConfirmWindow().ShowDialog<bool>(owner);
if (!confirmed) return;
ShutdownConfirmed = true;
CloseAllViews();
if (Application.Current?.ApplicationLifetime
is IClassicDesktopStyleApplicationLifetime desktop)
desktop.Shutdown();
}
finally
{
_shutdownDialogOpen = false;
}
}
/// <summary>Schließt alle offenen View-Fenster (beim Herunterfahren).</summary>
public void CloseAllViews()
{
foreach (var window in _open.Values.ToList())
window.Close();
_open.Clear();
}
}
+47
View File
@@ -0,0 +1,47 @@
using IBKRTrader.App.Views;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.App.Shell;
/// <summary>Registriert die Core-Ansichten (Dashboard, Workers, Logs, Settings) bei der Shell.</summary>
public static class CoreViews
{
public static void Register(IModuleUiHost host, IServiceProvider sp)
{
host.RegisterView(new ModuleView
{
Id = "core.dashboard", Title = "Dashboard", Group = "Core", Order = 5,
CreateView = () => new DashboardWindow(
host,
sp.GetRequiredService<DashboardService>(),
sp.GetRequiredService<SettingsService>(),
sp.GetServices<IModule>(),
sp.GetRequiredService<IConfiguration>(),
sp.GetServices<IWorker>())
});
host.RegisterView(new ModuleView
{
Id = "core.workers", Title = "Workers / Services", Group = "Core", Order = 10,
CreateView = () => new WorkersWindow(host, sp.GetRequiredService<WorkerEngine>())
});
host.RegisterView(new ModuleView
{
Id = "core.logs", Title = "Logs", Group = "Core", Order = 20,
CreateView = () => new LogsWindow(host, sp.GetRequiredService<LoggingService>())
});
host.RegisterView(new ModuleView
{
Id = "core.settings", Title = "Settings", Group = "Core", Order = 30,
CreateView = () => new SettingsWindow(host, sp.GetRequiredService<SettingsService>())
});
}
}
+72
View File
@@ -0,0 +1,72 @@
using IBKRTrader.App.Views.Modules;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services;
using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Persistence;
using IBKRTrader.Modules.Supervisor.Services;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.App.Shell;
/// <summary>
/// Registriert die Fenster der Module bei der Shell.
///
/// <para><b>Warum hier und nicht im Modul?</b> Ein Modul, das sein eigenes Fenster erzeugt, müsste
/// Avalonia referenzieren und wäre damit nicht mehr kopflos auf Linux lauffähig. Die
/// Modulprojekte bleiben deshalb frei von UI-Code; ihr <c>RegisterUi</c> ist leer, und die Shell
/// verdrahtet die Fenster zentral. Die Modul-Dienste kommen unverändert aus dem DI-Container.</para>
///
/// <para>Registriert wird nur, was auch geladen ist: fehlt ein Modul in dieser Sitzung, entfällt
/// sein Fenster, und der Launcher zeigt es gar nicht erst an.</para>
/// </summary>
public static class ModuleViews
{
public static void Register(IModuleUiHost host, IServiceProvider sp)
{
RegisterIfLoaded(sp, "CongressTrading", () => host.RegisterView(new ModuleView
{
Id = "congresstrading.main", Title = "Congress Trading", Group = "CongressTrading", Order = 100,
CreateView = () => new CongressTradingWindow(
host,
sp.GetRequiredService<CongressRepository>(),
sp.GetRequiredService<WorkerEngine>(),
sp.GetRequiredService<IPortfolioService>(),
sp.GetRequiredService<LoggingService>())
}));
RegisterIfLoaded(sp, "Supervisor", () => host.RegisterView(new ModuleView
{
Id = "supervisor.main", Title = "Supervisor", Group = "Supervisor", Order = 300,
CreateView = () => new SupervisorWindow(
host,
sp.GetRequiredService<SupervisorAgent>(),
sp.GetRequiredService<DossierService>(),
sp.GetRequiredService<ISupervisorReportRepository>(),
sp.GetRequiredService<LoggingService>())
}));
RegisterIfLoaded(sp, "Accounting", () => host.RegisterView(new ModuleView
{
Id = "accounting.main", Title = "Accounting", Group = "Accounting", Order = 400,
CreateView = () => new AccountingWindow(
host,
sp.GetRequiredService<ILedgerRepository>(),
sp.GetRequiredService<IIngestRunRepository>(),
sp.GetRequiredService<AccountingReportService>(),
sp.GetRequiredService<AccountingIngestService>(),
sp.GetRequiredService<LoggingService>())
}));
}
/// <summary>Registriert die Ansicht nur, wenn das Modul in dieser Sitzung geladen ist.</summary>
private static void RegisterIfLoaded(IServiceProvider sp, string moduleName, Action register)
{
if (sp.GetServices<IModule>().Any(m => string.Equals(m.Name, moduleName, StringComparison.OrdinalIgnoreCase)))
register();
}
}
+18
View File
@@ -0,0 +1,18 @@
using IBKRTrader.Core.Modularity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.App.Shell;
/// <summary>
/// Die Dienste, die es nur mit Oberfläche gibt. Alles andere kommt aus
/// <c>AppHostBuilder</c> und ist mit dem kopflosen Daemon geteilt.
/// </summary>
public static class ShellServices
{
public static void Register(IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton<AvaloniaUiHost>();
services.AddSingleton<IModuleUiHost>(sp => sp.GetRequiredService<AvaloniaUiHost>());
}
}
+95
View File
@@ -0,0 +1,95 @@
using IBKRTrader.App.ViewModels;
using IBKRTrader.App.Views;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Settings;
using IBKRTrader.Hosting;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.App.Shell;
/// <summary>
/// Konstruktionsprüfung aller Fenster ohne Message-Loop und ohne laufende Dienste.
///
/// <para>Nachfolger des <c>--smoke-ui</c>-Laufs der WinForms-Shell, der Konstruktionsfehler
/// zuverlässig gefangen hat. Der Host wird bewusst <b>nicht</b> gestartet: sonst liefen Worker,
/// Broker-Verbindungen und Marktdaten-Abrufe gegen die echten Endpunkte an für eine reine
/// Konstruktionsprüfung unerwünscht, auf einem Build-Server schlicht falsch.</para>
///
/// <para>Im Gegensatz zu WinForms braucht Avalonia dafür kein Anzeigegerät:
/// <c>SetupWithoutStarting</c> initialisiert das Framework, ohne ein Fenster zu zeigen. Damit ist
/// diese Prüfung erstmals CI-tauglich.</para>
/// </summary>
public static class SmokeUi
{
public static int Run()
{
Program.BuildAvaloniaApp().SetupWithoutStarting();
var modules = AppHostBuilder.CreateModules();
using var host = AppHostBuilder.Build(modules, ShellServices.Register);
App.Services = host.Services;
var uiHost = host.Services.GetRequiredService<AvaloniaUiHost>();
CoreViews.Register(uiHost, host.Services);
foreach (var module in modules)
module.RegisterUi(uiHost, host.Services);
ModuleViews.Register(uiHost, host.Services);
ViewIcons.AssignDefaults(uiHost);
var failures = 0;
Console.WriteLine("=== Smoke-UI: Fenster-Konstruktion (Avalonia) ===");
foreach (var view in uiHost.Views)
failures += Check(view.Id, view.Title, () => view.CreateView());
failures += Check("shell.launcher", "Launcher", () => new LauncherWindow(uiHost, host.Services));
failures += Check("shell.shutdown", "Beenden-Abfrage", () => new ShutdownConfirmWindow());
// Die Einstellungsmaske entsteht aus den Attributen von AppSettings. Ein Fenster kann
// fehlerfrei konstruieren und trotzdem leer sein, wenn die Attribute verlorengehen
// deshalb hier gegen die tatsächliche Feldzahl prüfen.
failures += CheckSettingsForm(host.Services.GetRequiredService<SettingsService>());
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
return failures == 0 ? 0 : 1;
}
private static int Check(string id, string title, Func<object> construct)
{
try
{
_ = construct();
Console.WriteLine($"[OK] {id} ({title})");
return 0;
}
catch (Exception ex)
{
Console.WriteLine($"[FEHLER] {id}: {ex.GetType().Name}: {ex.Message}");
return 1;
}
}
private static int CheckSettingsForm(SettingsService settings)
{
try
{
var sections = SettingsModelBuilder.Build(settings.Settings);
var fieldCount = sections.Sum(s => s.Fields.Count);
if (sections.Count == 0 || fieldCount == 0)
{
Console.WriteLine("[FEHLER] Einstellungsmaske: keine Felder aus AppSettings ermittelt " +
"(Category-/DisplayName-Attribute verloren?).");
return 1;
}
Console.WriteLine($"[OK] Einstellungsmaske: {sections.Count} Abschnitte, {fieldCount} Felder");
return 0;
}
catch (Exception ex)
{
Console.WriteLine($"[FEHLER] Einstellungsmaske: {ex.GetType().Name}: {ex.Message}");
return 1;
}
}
}
+69
View File
@@ -0,0 +1,69 @@
using System.Collections.Concurrent;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using IBKRTrader.Core.Modularity;
namespace IBKRTrader.App.Shell;
/// <summary>
/// Löst die toolkit-neutralen <see cref="ModuleView.IconKey"/>-Schlüssel gegen die
/// Avalonia-Bildressourcen auf. Gegenstück zum gleichnamigen Helfer der WinForms-Shell
/// <b>dieselben Schlüssel, dieselben PNG-Dateien</b>, sodass Core und Module unverändert bleiben.
/// </summary>
public static class ViewIcons
{
/// <summary>Symbol-Schlüssel → Dateiname unter <c>Resources/</c> (als Avalonia-Asset eingebettet).</summary>
private static readonly Dictionary<string, string> FileByKey = new(StringComparer.OrdinalIgnoreCase)
{
["dashboard"] = "dashboard.png",
["workers"] = "system_time.png",
["logs"] = "error_log.png",
["settings"] = "setting_tools.png",
["congresstrading"] = "cross_reference.png",
["accounting"] = "coins_in_hand.png",
["supervisor"] = "token_quantifier.png",
};
/// <summary>Standard-Symbolschlüssel je View-ID identisch zur WinForms-Shell.</summary>
private static readonly Dictionary<string, string> DefaultKeyByViewId = new(StringComparer.OrdinalIgnoreCase)
{
["core.dashboard"] = "dashboard",
["core.workers"] = "workers",
["core.logs"] = "logs",
["core.settings"] = "settings",
["congresstrading.main"] = "congresstrading",
["accounting.main"] = "accounting",
["supervisor.main"] = "supervisor",
};
private static readonly ConcurrentDictionary<string, Bitmap?> Cache = new();
/// <summary>Bild zum Schlüssel, oder <c>null</c> (kein Symbol / Datei fehlt).</summary>
public static Bitmap? Resolve(string? iconKey)
{
if (iconKey is null || !FileByKey.TryGetValue(iconKey, out var file)) return null;
return Cache.GetOrAdd(iconKey, _ =>
{
try
{
using var stream = AssetLoader.Open(
new Uri($"avares://IBKRTrader.App/Assets/{file}"));
return new Bitmap(stream);
}
catch
{
// Ein fehlendes Symbol darf die Oberfläche nie aufhalten dann eben nur Text.
return null;
}
});
}
/// <summary>Setzt bei allen registrierten Views den Standard-Schlüssel, falls noch keiner gesetzt ist.</summary>
public static void AssignDefaults(IModuleUiHost host)
{
foreach (var view in host.Views)
if (view.IconKey is null && DefaultKeyByViewId.TryGetValue(view.Id, out var key))
view.IconKey = key;
}
}
+92
View File
@@ -0,0 +1,92 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using IBKRTrader.Core.Modularity;
namespace IBKRTrader.App.Shell;
/// <summary>
/// Baut das gemeinsame Fenster-Menü, das auf JEDEM Fenster erscheint und das Wechseln zwischen
/// allen Fenstern (Launcher + Core + Module) erlaubt. Es nutzt nur den Core-Contract
/// <see cref="IModuleUiHost"/> und funktioniert deshalb auch aus Modul-Fenstern.
///
/// <para>Gegenstück zum gleichnamigen WinForms-Helfer gleiches Verhalten, anderes Toolkit.</para>
/// </summary>
public static class WindowMenu
{
/// <summary>
/// Verdrahtet ein <see cref="Menu"/> mit der Fensterliste: füllt es sofort und baut es bei
/// jeder Änderung des Offen-Status neu auf. Die Registrierung wird beim Entladen gelöst.
/// </summary>
/// <param name="currentViewId">ID der eigenen View, oder <c>null</c> im Launcher.</param>
public static void Wire(Menu menu, IModuleUiHost host, string? currentViewId)
{
void Refresh()
{
// Der Offen-Status kann aus einem beliebigen Fenster gemeldet werden der Aufbau
// der Menüleiste gehört aber auf den UI-Thread.
if (Dispatcher.UIThread.CheckAccess()) Populate(menu, host, currentViewId);
else Dispatcher.UIThread.Post(() => Populate(menu, host, currentViewId));
}
Populate(menu, host, currentViewId);
host.OpenStateChanged += Refresh;
menu.DetachedFromVisualTree += (_, _) => host.OpenStateChanged -= Refresh;
}
/// <summary>Baut die Menüleiste komplett neu auf.</summary>
public static void Populate(Menu menu, IModuleUiHost host, string? currentViewId)
{
var items = new List<Control>
{
BuildItem("Launcher", null, isCurrent: currentViewId is null,
isOpen: false, onClick: host.ActivateMain)
};
foreach (var view in host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
{
var id = view.Id;
items.Add(BuildItem(view.Title, view.IconKey,
isCurrent: id == currentViewId,
isOpen: host.IsOpen(id),
onClick: () => host.OpenView(id)));
}
// Kontextabhängige rechte Aktion: nur der Launcher darf die Anwendung beenden; jedes
// andere Fenster bietet nur „Fenster schließen" (Module laufen weiter).
if (currentViewId is null)
{
items.Add(BuildItem("Beenden", null, false, false, host.RequestShutdown, alignRight: true));
}
else
{
items.Add(BuildItem("Fenster schließen", null, false, false,
() => (menu.GetVisualRoot() as Window)?.Close(), alignRight: true));
}
menu.ItemsSource = items;
}
private static MenuItem BuildItem(string title, string? iconKey, bool isCurrent, bool isOpen,
Action onClick, bool alignRight = false)
{
var item = new MenuItem
{
Header = title,
FontWeight = isCurrent ? FontWeight.Bold : FontWeight.Normal,
// Offene Fenster werden hervorgehoben Ersatz für das Häkchen der WinForms-Leiste.
Foreground = isOpen && !isCurrent ? Brushes.SteelBlue : null,
};
if (alignRight) item.HorizontalAlignment = HorizontalAlignment.Right;
var icon = ViewIcons.Resolve(iconKey);
if (icon is not null)
item.Icon = new Image { Source = icon, Width = 16, Height = 16 };
item.Click += (_, _) => onClick();
return item;
}
}
+47
View File
@@ -0,0 +1,47 @@
using Avalonia.Media;
namespace IBKRTrader.App.ViewModels;
/// <summary>
/// Zeilentypen für die DataGrids der Core-Ansichten.
///
/// <para>Bewusst benannte Records statt der anonymen Typen, die die WinForms-Fassung an
/// <c>DataGridView.DataSource</c> gehängt hat: Avalonias <c>DataGrid</c> bindet über
/// kompilierte Bindings gegen einen bekannten Typ. Anonyme Typen sind <c>internal</c> und
/// funktionieren dort nur über Reflexion mit benannten Records bleibt die Spaltendefinition
/// im XAML prüfbar.</para>
/// </summary>
public sealed record ModuleRow(string Name, string Prefix, string Status);
/// <summary>
/// Eine Zeile im Live-Log. Die Farbe hängt am Eintrag statt an einer Selektion WinForms färbte
/// über <c>SelectionColor</c> der RichTextBox ein, in Avalonia wird je Element gebunden.
/// </summary>
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 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);
// ─── Accounting ──────────────────────────────────────────────────────────────
// Beträge als bereits formatierter Text: die Umrechnung in die Anzeigewährung passiert beim
// Laden, und das Format ist damit an einer Stelle festgelegt statt in jeder Spaltendefinition.
/// <summary>Eine Monatszeile des Monatsvergleichs (BWA).</summary>
public sealed record MonthlyRow(
string Month, string Opening, string Deposits, string Withdrawals,
string Volume, string Fees, string Result, string Closing);
/// <summary>Eine Buchung im neutralen Ledger.</summary>
public sealed record LedgerRow(
DateTime Time, string AccountId, string EventType, string Side, string Symbol,
string Currency, decimal Quantity, decimal Price,
decimal Gross, decimal Fee, decimal Net, string TransactionId);
/// <summary>Ein Ingest-Lauf des Flex-Query-Abrufs.</summary>
public sealed record IngestRunRow(
string AccountId, DateTime Started, DateTime? Finished, bool Backfill,
int NewEntries, int DuplicateEntries, bool Success, string Delta, string Message);
@@ -0,0 +1,87 @@
using System.ComponentModel;
using System.Reflection;
namespace IBKRTrader.App.ViewModels;
/// <summary>Ein bearbeitbares Einzelfeld der Einstellungen.</summary>
public sealed class SettingsField
{
public required string DisplayName { get; init; }
public required string Description { get; init; }
public required Type ValueType { get; init; }
public required bool IsPassword { get; init; }
public required Func<object?> Get { get; init; }
public required Action<object?> Set { get; init; }
}
/// <summary>Ein Abschnitt (entspricht einem aufklappbaren Knoten des früheren PropertyGrid).</summary>
public sealed record SettingsSection(string Title, IReadOnlyList<SettingsField> Fields);
/// <summary>
/// Baut die Eingabemaske der Einstellungen aus den Attributen von <c>AppSettings</c>.
///
/// <para><b>Warum aus Attributen:</b> Die WinForms-Fassung zeigte <c>AppSettings</c> in einem
/// <c>PropertyGrid</c>. Avalonia hat dafür kein Gegenstück. Die Klassen tragen bereits
/// <see cref="CategoryAttribute"/>, <see cref="DisplayNameAttribute"/> und
/// <see cref="DescriptionAttribute"/> daraus lässt sich die Maske erzeugen, statt sie von Hand
/// zu pflegen. Eine neue Einstellung erscheint damit automatisch, ohne dass jemand die Oberfläche
/// anfasst; genau das war der Vorteil des PropertyGrid, und er bleibt erhalten.</para>
/// </summary>
public static class SettingsModelBuilder
{
/// <summary>Typen, die als Eingabefeld dargestellt werden. Alles andere gilt als Unterabschnitt.</summary>
private static bool IsLeaf(Type t) =>
t == typeof(string) || t.IsEnum ||
t == typeof(int) || t == typeof(long) || t == typeof(double) ||
t == typeof(decimal) || t == typeof(bool);
/// <summary>Zerlegt das Einstellungsobjekt in Abschnitte mit Feldern.</summary>
public static IReadOnlyList<SettingsSection> Build(object root)
{
var sections = new List<SettingsSection>();
Walk(root, prefix: null, sections);
return sections;
}
private static void Walk(object owner, string? prefix, List<SettingsSection> sections)
{
var fields = new List<SettingsField>();
foreach (var prop in owner.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (!prop.CanRead || prop.GetIndexParameters().Length > 0) continue;
var title = prop.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? prop.Name;
if (IsLeaf(prop.PropertyType))
{
if (!prop.CanWrite) continue; // z. B. berechnete Eigenschaften
var target = owner; // für den Abschluss festhalten
fields.Add(new SettingsField
{
DisplayName = title,
Description = prop.GetCustomAttribute<DescriptionAttribute>()?.Description ?? "",
ValueType = prop.PropertyType,
IsPassword = prop.GetCustomAttribute<PasswordPropertyTextAttribute>()?.Password == true,
Get = () => prop.GetValue(target),
Set = v => prop.SetValue(target, v)
});
continue;
}
// Verschachteltes Einstellungsobjekt → eigener Abschnitt. Nur eigene Typen verfolgen,
// damit die Rekursion nicht in Framework-Typen abbiegt.
if (prop.PropertyType.IsClass && prop.PropertyType.Namespace?.StartsWith("IBKRTrader") == true)
{
var child = prop.GetValue(owner);
if (child is not null)
Walk(child, prefix is null ? title : $"{prefix} · {title}", sections);
}
}
if (fields.Count > 0)
sections.Add(new SettingsSection(prefix ?? "Allgemein", fields));
}
}
@@ -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}";
}
+45
View File
@@ -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).&#10;&#10;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.&#10;&#10;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 023." + 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}";
}
+11
View File
@@ -0,0 +1,11 @@
{
"Database": {
"MySqlConnectionString": ""
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Warning"
}
}
}