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