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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user