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
@@ -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}";
}