Files
PolyTraderSharp/src/PolyTrader.App.Avalonia/Views/Modules/AccountingWindow.axaml.cs
T
RichardandClaude Opus 5 669676ac60 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>
2026-08-06 22:19:42 +02:00

254 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}
}