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,317 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using PolyTrader.App.Avalonia.Controls;
using PolyTrader.App.Avalonia.ViewModels;
using PolyTrader.Core.Modularity;
using PolyTrader.Modules.CopyTrading.Persistence;
using PolyTraderSharp;
using PolyTraderSharp.Models;
namespace PolyTrader.App.Avalonia.Views.Modules
{
/// <summary>
/// Copytrading-Fenster mit vier Tabs: Master-Trader, Offene Trades, Geschlossene Trades,
/// Account-Einstellungen. Layout vollständig in CopyTradingWindow.axaml.
/// </summary>
public partial class CopyTradingWindow : Window
{
private readonly ObservableCollection<TrackedTrader> _traders = new();
private readonly ObservableCollection<AccountAssignment> _assignments = new();
private readonly ObservableCollection<CopyOpenTradeRow> _openTrades = new();
private readonly ObservableCollection<CopyClosedTradeRow> _closedTrades = new();
private readonly TradingState _state;
private readonly CopyTradingState _copyState;
private readonly ITrackedTraderRepository _traderRepo;
private readonly ICopyTradingAccountSettingsRepository _settingsRepo;
private readonly ICopyTradeLogRepository _tradeLog;
private List<CopyClosedTradeRow> _closedBase = new();
private bool _loading;
public CopyTradingWindow() => AvaloniaXamlLoader.Load(this);
public CopyTradingWindow(IModuleUiHost host, TradingState state, CopyTradingState copyState,
ITrackedTraderRepository traderRepo, ICopyTradingAccountSettingsRepository settingsRepo,
ICopyTradeLogRepository tradeLog) : this()
{
this.FindControl<WindowMenuBar>("menuBar")!.Attach(host, "copytrading.main", this);
_state = state;
_copyState = copyState;
_traderRepo = traderRepo;
_settingsRepo = settingsRepo;
_tradeLog = tradeLog;
// --- Master-Trader ---
var gridTraders = this.FindControl<DataGrid>("gridTraders")!;
gridTraders.ItemsSource = _traders;
gridTraders.SelectionChanged += (_, _) => ShowSelectedTrader();
this.FindControl<ListBox>("listAccounts")!.ItemsSource = _assignments;
this.FindControl<Button>("btnTraderRefresh")!.Click += (_, _) => LoadTraders();
this.FindControl<Button>("btnTraderSave")!.Click += (_, _) => SaveSelectedTrader();
// --- Offene Trades ---
var gridOpen = this.FindControl<DataGrid>("gridOpen")!;
gridOpen.ItemsSource = _openTrades;
// Zeilenfärbung nach Buchgewinn: die Kategorie kommt aus der getesteten TradeRowColoring,
// die Farbe aus TradeRowPalette. LoadingRow greift auch bei virtualisierten Zeilen.
gridOpen.LoadingRow += (_, e) =>
{
if (e.Row.DataContext is CopyOpenTradeRow row) e.Row.Background = row.RowBrush;
};
this.FindControl<Button>("btnOpenRefresh")!.Click += (_, _) => LoadOpenTrades();
// --- Geschlossene Trades ---
var gridClosed = this.FindControl<DataGrid>("gridClosed")!;
gridClosed.ItemsSource = _closedTrades;
gridClosed.LoadingRow += (_, e) =>
{
if (e.Row.DataContext is CopyClosedTradeRow row) e.Row.Background = row.RowBrush;
};
var cbResult = this.FindControl<ComboBox>("cbClosedResult")!;
cbResult.ItemsSource = new[] { "Alle", "Gewinner", "Verlierer" };
cbResult.SelectedIndex = 0;
this.FindControl<Button>("btnClosedRefresh")!.Click += (_, _) => LoadClosedTrades();
this.FindControl<Button>("btnClosedReset")!.Click += (_, _) => ResetClosedFilter();
this.FindControl<TextBox>("tbClosedMarket")!.TextChanged += (_, _) => { if (!_loading) ApplyClosedFilter(); };
foreach (var n in new[] { "cbClosedMaster", "cbClosedResult" })
this.FindControl<ComboBox>(n)!.SelectionChanged += (_, _) => { if (!_loading) ApplyClosedFilter(); };
foreach (var n in new[] { "dtClosedFrom", "dtClosedTo" })
this.FindControl<CalendarDatePicker>(n)!.SelectedDateChanged += (_, _) => { if (!_loading) ApplyClosedFilter(); };
// --- Account-Einstellungen ---
var cbAcc = this.FindControl<ComboBox>("cbSettingsAccount")!;
cbAcc.SelectionChanged += (_, _) => ShowAccountSettings();
this.FindControl<Button>("btnSettingsSave")!.Click += (_, _) => SaveAccountSettings();
LoadTraders();
LoadOpenTrades();
LoadClosedTrades();
LoadAccountList();
}
// ===== Master-Trader =====
private void LoadTraders()
{
_traders.Clear();
foreach (var t in _copyState.Traders.Values.OrderBy(t => t.Id)) _traders.Add(t);
var grid = this.FindControl<DataGrid>("gridTraders")!;
if (_traders.Count > 0 && grid.SelectedItem == null) grid.SelectedIndex = 0;
}
private void ShowSelectedTrader()
{
var trader = this.FindControl<DataGrid>("gridTraders")!.SelectedItem as TrackedTrader;
this.FindControl<SettingsEditor>("editorTrader")!.Show(trader);
_assignments.Clear();
if (trader == null) return;
foreach (var acc in _state.Accounts.Values.OrderBy(a => a.AccountId))
_assignments.Add(new AccountAssignment
{
AccountId = acc.AccountId,
Label = $"#{acc.AccountId} {acc.Name}{(acc.IsDemo ? " (Demo)" : "")}",
IsAssigned = trader.AssignedAccountIds.Contains(acc.AccountId)
});
}
private void SaveSelectedTrader()
{
if (this.FindControl<DataGrid>("gridTraders")!.SelectedItem is not TrackedTrader trader) return;
trader.AssignedAccountIds = _assignments.Where(a => a.IsAssigned).Select(a => a.AccountId).ToHashSet();
_traderRepo.Update(trader);
_copyState.Traders[trader.Id] = trader; // Hot-Path-State synchron halten
this.FindControl<TextBlock>("lblTraderHint")!.Text =
$"Gespeichert: #{trader.Id} {trader.DisplayName} ({trader.AssignedAccountIds.Count} Account(s)) um {DateTime.Now:HH:mm:ss}.";
}
// ===== Offene Trades =====
private void LoadOpenTrades()
{
_openTrades.Clear();
foreach (var acc in _state.Accounts.Values.OrderBy(a => a.AccountId))
foreach (var p in acc.OpenPositions.Values.OrderByDescending(p => p.OpenedAt))
{
decimal unrealized = p.CurrentValueUsd - p.AmountUsd;
decimal pct = p.AmountUsd > 0m ? 100m * unrealized / p.AmountUsd : 0m;
_openTrades.Add(new CopyOpenTradeRow
{
Account = acc.Name + (acc.IsDemo ? " (Demo)" : ""),
Trader = string.IsNullOrEmpty(p.SourceTraderName) ? $"#{p.SourceTraderId}" : p.SourceTraderName,
Market = p.MarketQuestion,
Outcome = p.Outcome,
Side = p.Side,
Entry = p.EntryPrice,
Current = p.CurrentPrice,
Size = p.Size,
Amount = p.AmountUsd,
Value = p.CurrentValueUsd,
Unrealized = unrealized,
UnrealizedPct = pct,
Status = p.ExitPending ? "Exit läuft" : "Offen"
});
}
decimal totalAmount = _openTrades.Sum(r => r.Amount);
decimal totalUnreal = _openTrades.Sum(r => r.Unrealized);
this.FindControl<TextBlock>("lblOpenSummary")!.Text =
$"{_openTrades.Count} offene Position(en) · Einsatz {totalAmount:N2} USDC · Buchgewinn {totalUnreal:N2} USDC";
}
// ===== Geschlossene Trades =====
private void LoadClosedTrades()
{
try
{
var trades = _tradeLog.Find(_ => true);
_closedBase = trades
.OrderByDescending(t => t.ClosedAt)
.Select(t => new CopyClosedTradeRow
{
TradeId = t.TradeId,
Account = ResolveAccount(t.AccountId, t.IsDemo),
Trader = ResolveTrader(t.SourceTraderId),
Market = t.MarketQuestion,
Outcome = t.Outcome,
Side = t.Side,
Entry = t.EntryPrice,
Exit = t.ExitPrice,
Size = t.Size,
Pnl = t.RealizedPnl,
PnlPercent = t.PnlPercent,
OpenedAt = t.OpenedAt,
ClosedAt = t.ClosedAt
})
.ToList();
}
catch
{
_closedBase = new List<CopyClosedTradeRow>(); // DB nicht bereit -> leer statt Absturz
}
_loading = true;
var cbMaster = this.FindControl<ComboBox>("cbClosedMaster")!;
var masters = new List<string> { "Alle Master" };
masters.AddRange(_closedBase.Select(r => r.Trader).Distinct().OrderBy(x => x));
cbMaster.ItemsSource = masters;
cbMaster.SelectedIndex = 0;
_loading = false;
ApplyClosedFilter();
}
/// <summary>Kontoname aus dem State; fällt auf die ID zurück, wenn das Konto fehlt.</summary>
private string ResolveAccount(int accountId, bool isDemo)
{
string suffix = isDemo ? " (Demo)" : "";
return _state.Accounts.TryGetValue(accountId, out var acc) && !string.IsNullOrEmpty(acc.Name)
? acc.Name + suffix
: $"#{accountId}{suffix}";
}
private string ResolveTrader(int traderId) =>
_copyState.Traders.TryGetValue(traderId, out var t) && !string.IsNullOrEmpty(t.DisplayName)
? t.DisplayName
: $"#{traderId}";
private void ResetClosedFilter()
{
_loading = true;
this.FindControl<TextBox>("tbClosedMarket")!.Text = string.Empty;
this.FindControl<ComboBox>("cbClosedMaster")!.SelectedIndex = 0;
this.FindControl<ComboBox>("cbClosedResult")!.SelectedIndex = 0;
this.FindControl<CalendarDatePicker>("dtClosedFrom")!.SelectedDate = null;
this.FindControl<CalendarDatePicker>("dtClosedTo")!.SelectedDate = null;
_loading = false;
ApplyClosedFilter();
}
private void ApplyClosedFilter()
{
string market = this.FindControl<TextBox>("tbClosedMarket")!.Text?.Trim() ?? string.Empty;
string master = this.FindControl<ComboBox>("cbClosedMaster")!.SelectedItem as string ?? "Alle Master";
string result = this.FindControl<ComboBox>("cbClosedResult")!.SelectedItem as string ?? "Alle";
DateTime? from = this.FindControl<CalendarDatePicker>("dtClosedFrom")!.SelectedDate;
DateTime? to = this.FindControl<CalendarDatePicker>("dtClosedTo")!.SelectedDate;
IEnumerable<CopyClosedTradeRow> rows = _closedBase;
if (market.Length > 0)
rows = rows.Where(r => r.Market.Contains(market, StringComparison.OrdinalIgnoreCase));
if (master != "Alle Master") rows = rows.Where(r => r.Trader == master);
if (result == "Gewinner") rows = rows.Where(r => r.Pnl > 0m);
else if (result == "Verlierer") rows = rows.Where(r => r.Pnl < 0m);
if (from.HasValue) rows = rows.Where(r => r.ClosedAt >= from.Value.Date);
if (to.HasValue) rows = rows.Where(r => r.ClosedAt < to.Value.Date.AddDays(1));
var list = rows.ToList();
_closedTrades.Clear();
foreach (var r in list) _closedTrades.Add(r);
decimal pnl = list.Sum(r => r.Pnl);
int wins = list.Count(r => r.Pnl > 0m);
decimal winrate = list.Count > 0 ? 100m * wins / list.Count : 0m;
this.FindControl<TextBlock>("lblClosedSummary")!.Text =
$"{list.Count} Trade(s) · PnL {pnl:N2} USDC · Winrate {winrate:N1} %";
}
// ===== Account-Einstellungen =====
private void LoadAccountList()
{
var cb = this.FindControl<ComboBox>("cbSettingsAccount")!;
var items = _state.Accounts.Values.OrderBy(a => a.AccountId)
.Select(a => new AccountChoice(a.AccountId, $"#{a.AccountId} {a.Name}{(a.IsDemo ? " (Demo)" : "")}"))
.ToList();
cb.ItemsSource = items;
if (items.Count > 0) cb.SelectedIndex = 0;
}
private void ShowAccountSettings()
{
var editor = this.FindControl<SettingsEditor>("editorAccount")!;
if (this.FindControl<ComboBox>("cbSettingsAccount")!.SelectedItem is not AccountChoice choice)
{
editor.Show(null);
return;
}
// Fehlt für ein Konto noch ein Eintrag, wird er hier angelegt – wie bei der Hydration.
if (!_copyState.AccountSettings.TryGetValue(choice.Id, out var settings))
{
settings = new CopyTradingAccountSettings { AccountId = choice.Id };
_copyState.AccountSettings[choice.Id] = settings;
}
editor.Show(settings);
}
private void SaveAccountSettings()
{
if (this.FindControl<ComboBox>("cbSettingsAccount")!.SelectedItem is not AccountChoice choice) return;
if (!_copyState.AccountSettings.TryGetValue(choice.Id, out var settings)) return;
_settingsRepo.Upsert(settings);
this.FindControl<TextBlock>("lblSettingsHint")!.Text =
$"Gespeichert für Account #{choice.Id} um {DateTime.Now:HH:mm:ss}.";
}
private sealed record AccountChoice(int Id, string Label)
{
public override string ToString() => Label;
}
}
}