Modul-Fenster nach Avalonia - UI-Portierung inhaltlich abgeschlossen

Alle acht Fenster laufen jetzt unter Avalonia: Launcher, Dashboard, Settings, Terminal,
Server Jobs, Copytrading, ResolutionFarming, Supervisor, Accounting (+ Shutdown- und
Allzweck-Dialog).

- Copytrading: vier Tabs (Master-Trader mit Account-Zuweisung, Offene Trades, Geschlossene
  Trades mit Filterleiste, Account-Einstellungen). Zeilenfaerbung nach PnL laeuft ueber
  DataGrid.LoadingRow - greift damit auch bei virtualisierten Zeilen, anders als das
  fruehere Faerben in DataBindingComplete.
- ResolutionFarming: Kandidaten, Positionen, Historie, Settings - je Konto.
- Supervisor: Analyse-Chat gegen den Agenten (Tool-Fortschritt in der Statuszeile),
  Dossiers mit Markdown-Ansicht, Berichte, Counterfactuals.
- Accounting: KPI-Kacheln, Monats-BWA, Ledger, Abruf/Status, PDF- und CSV-Export ueber
  den plattformneutralen Datei-Dialog.

Der SettingsEditor traegt wie erwartet die drei restlichen PropertyGrid-Stellen
(Master-Trader, Account-Einstellungen, ResolutionFarming) mit je einem Aufruf.

ENTSCHEIDUNG - Modul-Fenster liegen in der App, nicht in den Modulen
(begruendet in Views/Modules/README.md): Die App ist der Kompositionswurzel und
referenziert ohnehin alle Module. So bleiben die Modulprojekte FREI VON AVALONIA, was fuer
den kopflosen Linux-Betrieb den Ausschlag gibt - der Daemon soll keine GUI-Bibliothek
mitschleppen. Verifiziert: kein Modul zieht Avalonia. Registrierung in Shell/ModuleViews.cs,
und zwar nur fuer tatsaechlich geladene Module - ein per DisabledModules abgeschaltetes
Modul bekommt gar kein Fenster.

Nebenbei: eigene View-Model-Typen CopyOpenTradeRow/CopyClosedTradeRow statt des
Modul-Modells ClosedTradeRow - sie tragen den aufgeloesten Master-Trader-Namen und die
Zeilenfarbe, die es dort nicht gibt (und vermeiden die Namensmehrdeutigkeit).

Verifiziert: Solution baut, 450 Tests gruen, --smoke-ui gruen (alle 8 Fenster + Launcher +
Dialog + Editor-Pruefung), App laeuft real mit allen Trading-Diensten, Linux-Publish laeuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 22:19:42 +02:00
co-authored by Claude Opus 5
parent 929fa20ee0
commit 669676ac60
15 changed files with 1658 additions and 2 deletions
@@ -0,0 +1,253 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using PolyTrader.App.Avalonia.ViewModels;
using PolyTrader.Core.Modularity;
using PolyTrader.Modules.Accounting.Logic;
using PolyTrader.Modules.Accounting.Models;
using PolyTrader.Modules.Accounting.Persistence;
using PolyTrader.Modules.Accounting.Services;
using PolyTraderSharp;
using PolyTraderSharp.Services;
namespace PolyTrader.App.Avalonia.Views.Modules
{
/// <summary>
/// Accounting: Periodenabrechnung mit KPI-Kacheln und Monatsübersicht, Ledger-Ansicht und
/// Abruf-/Status-Tab. Layout vollständig in AccountingWindow.axaml.
/// </summary>
public partial class AccountingWindow : Window
{
private readonly ObservableCollection<MonthlyRow> _monthly = new();
private readonly ObservableCollection<LedgerEntry> _ledger = new();
private readonly ObservableCollection<IngestRun> _runs = new();
private readonly TradingState _state = null!;
private readonly AccountingReportService _reports = null!;
private readonly ILedgerRepository _ledgerRepo = null!;
private readonly IIngestRunRepository _runRepo = null!;
private readonly AccountingIngestService _ingest = null!;
private readonly TerminalLogger _logger = null!;
private PeriodStatement? _statement;
private CurrencyContext _currency = new("USDC", 1m, true, string.Empty);
public AccountingWindow() => AvaloniaXamlLoader.Load(this);
public AccountingWindow(IModuleUiHost host, TradingState state, AccountingReportService reports,
ILedgerRepository ledgerRepo, IIngestRunRepository runRepo,
AccountingIngestService ingest, TerminalLogger logger) : this()
{
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "accounting.main", this);
_state = state;
_reports = reports;
_ledgerRepo = ledgerRepo;
_runRepo = runRepo;
_ingest = ingest;
_logger = logger;
this.FindControl<DataGrid>("gridMonthly")!.ItemsSource = _monthly;
this.FindControl<DataGrid>("gridLedger")!.ItemsSource = _ledger;
this.FindControl<DataGrid>("gridRuns")!.ItemsSource = _runs;
var cbCurrency = this.FindControl<ComboBox>("cbCurrency")!;
cbCurrency.ItemsSource = new[] { "USDC", "USD", "EUR" };
cbCurrency.SelectedIndex = 0;
// Vorbelegung wie bisher: laufender Monat.
var now = DateTime.Now;
this.FindControl<CalendarDatePicker>("dtFrom")!.SelectedDate = new DateTime(now.Year, now.Month, 1);
this.FindControl<CalendarDatePicker>("dtTo")!.SelectedDate = now.Date;
FillAccountCombos();
this.FindControl<Button>("btnCompute")!.Click += (_, _) => Compute();
this.FindControl<Button>("btnPdf")!.Click += async (_, _) => await ExportAsync(pdf: true);
this.FindControl<Button>("btnCsv")!.Click += async (_, _) => await ExportAsync(pdf: false);
this.FindControl<Button>("btnLedgerRefresh")!.Click += (_, _) => LoadLedger();
this.FindControl<Button>("btnStatusRefresh")!.Click += (_, _) => LoadRuns();
this.FindControl<Button>("btnIngestNow")!.Click += async (_, _) => await RunIngestAsync(backfill: false);
this.FindControl<Button>("btnBackfill")!.Click += async (_, _) => await RunIngestAsync(backfill: true);
Compute();
LoadLedger();
LoadRuns();
}
private void Status(string text) => this.FindControl<TextBlock>("lblStatus")!.Text = text;
private void FillAccountCombos()
{
var items = new List<AccountChoice> { new(null, "Alle Konten") };
items.AddRange(_state.Accounts.Values.OrderBy(a => a.AccountId)
.Select(a => new AccountChoice(a.AccountId, $"#{a.AccountId} {a.Name}{(a.IsDemo ? " (Demo)" : "")}")));
foreach (var name in new[] { "cbAccount", "cbLedgerAccount", "cbStatusAccount" })
{
var cb = this.FindControl<ComboBox>(name)!;
cb.ItemsSource = items.ToList();
cb.SelectedIndex = 0;
}
}
private int? AccountIdOf(string comboName) =>
(this.FindControl<ComboBox>(comboName)!.SelectedItem as AccountChoice)?.Id;
// ===== Übersicht / BWA =====
private void Compute()
{
try
{
DateTime from = this.FindControl<CalendarDatePicker>("dtFrom")!.SelectedDate?.Date ?? DateTime.Today;
DateTime to = this.FindControl<CalendarDatePicker>("dtTo")!.SelectedDate?.Date ?? DateTime.Today;
int? accountId = AccountIdOf("cbAccount");
string code = this.FindControl<ComboBox>("cbCurrency")!.SelectedItem as string ?? "USDC";
_currency = _reports.ResolveCurrency(code, to);
_statement = _reports.BuildStatement(accountId, from, to);
string unit = _currency.Code;
decimal V(decimal usdc) => AccountingReportService.Convert(usdc, _currency);
this.FindControl<TextBlock>("kpiNet")!.Text = $"{V(_statement.NetTradingResultUsdc):N2} {unit}";
this.FindControl<TextBlock>("kpiClosing")!.Text = $"{V(_statement.ClosingBalanceUsdc):N2} {unit}";
this.FindControl<TextBlock>("kpiDeposits")!.Text = $"{V(_statement.Deposits):N2} {unit}";
this.FindControl<TextBlock>("kpiWithdrawals")!.Text = $"{V(_statement.Withdrawals):N2} {unit}";
this.FindControl<TextBlock>("kpiFees")!.Text = $"{V(_statement.Fees):N2} {unit}";
this.FindControl<TextBlock>("kpiRewards")!.Text = $"{V(_statement.Rewards):N2} {unit}";
this.FindControl<TextBlock>("kpiVolume")!.Text = $"{V(_statement.TradeVolume):N2} {unit}";
this.FindControl<TextBlock>("kpiTrades")!.Text = _statement.TradeCount.ToString();
this.FindControl<TextBlock>("lblCurrencyNote")!.Text = _currency.Note;
_monthly.Clear();
foreach (var m in _reports.BuildMonthly(accountId, from, to))
_monthly.Add(new MonthlyRow
{
Month = m.From.ToString("MM/yyyy"),
Net = $"{V(m.NetTradingResultUsdc):N2}",
Deposits = $"{V(m.Deposits):N2}",
Withdrawals = $"{V(m.Withdrawals):N2}",
Fees = $"{V(m.Fees):N2}",
Volume = $"{V(m.TradeVolume):N2}",
Trades = m.TradeCount,
Closing = $"{V(m.ClosingBalanceUsdc):N2}"
});
Status($"Abrechnung berechnet ({from:dd.MM.yyyy}{to:dd.MM.yyyy}, {unit}).");
}
catch (Exception ex)
{
Status($"Berechnung fehlgeschlagen: {ex.Message}");
}
}
// ===== Export =====
private async Task ExportAsync(bool pdf)
{
if (_statement == null) { Status("Bitte zuerst berechnen."); return; }
try
{
var picker = await StorageProvider.SaveFilePickerAsync(new global::Avalonia.Platform.Storage.FilePickerSaveOptions
{
Title = pdf ? "PDF-Export" : "CSV-Export",
SuggestedFileName = $"abrechnung_{_statement.From:yyyyMMdd}_{_statement.To:yyyyMMdd}" + (pdf ? ".pdf" : ".csv"),
DefaultExtension = pdf ? "pdf" : "csv"
});
if (picker == null) return;
string path = picker.Path.LocalPath;
int? accountId = AccountIdOf("cbAccount");
var entries = _ledgerRepo.Query(accountId, _statement.From, _statement.To, 100000);
if (pdf)
{
byte[] bytes = PdfExporter.Render(_statement,
_reports.BuildMonthly(accountId, _statement.From, _statement.To),
entries, _currency.Code, _currency.Factor, _currency.Note);
await File.WriteAllBytesAsync(path, bytes);
}
else
{
await File.WriteAllTextAsync(path, CsvExporter.Statement(_statement) + "\n" + CsvExporter.Ledger(entries));
}
_logger.Info($"Accounting-Export geschrieben: {path}");
Status($"Export gespeichert: {path}");
}
catch (Exception ex)
{
Status($"Export fehlgeschlagen: {ex.Message}");
}
}
// ===== Ledger =====
private void LoadLedger()
{
try
{
_ledger.Clear();
foreach (var e in _ledgerRepo.Query(AccountIdOf("cbLedgerAccount"), null, null, 5000))
_ledger.Add(e);
Status($"{_ledger.Count} Ledger-Sätze geladen.");
}
catch (Exception ex)
{
Status($"Ledger konnte nicht geladen werden: {ex.Message}");
}
}
// ===== Abruf / Status =====
private void LoadRuns()
{
try
{
_runs.Clear();
foreach (var r in _runRepo.GetRecent(AccountIdOf("cbStatusAccount"), 200)) _runs.Add(r);
}
catch (Exception ex)
{
Status($"Abrufhistorie konnte nicht geladen werden: {ex.Message}");
}
}
private async Task RunIngestAsync(bool backfill)
{
var incremental = this.FindControl<Button>("btnIngestNow")!;
var full = this.FindControl<Button>("btnBackfill")!;
incremental.IsEnabled = full.IsEnabled = false;
try
{
Status(backfill ? "Backfill läuft …" : "Inkrementeller Abruf läuft …");
await _ingest.IngestAllAsync(backfill, CancellationToken.None);
LoadRuns();
LoadLedger();
Status($"Abruf abgeschlossen um {DateTime.Now:HH:mm:ss}.");
}
catch (Exception ex)
{
Status($"Abruf fehlgeschlagen: {ex.Message}");
}
finally
{
incremental.IsEnabled = full.IsEnabled = true;
}
}
private sealed record AccountChoice(int? Id, string Label)
{
public override string ToString() => Label;
}
}
}