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>
254 lines
11 KiB
C#
254 lines
11 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|