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
+1
View File
@@ -33,6 +33,7 @@ namespace PolyTrader.App.Avalonia
CoreViews.Register(uiHost, Services);
foreach (var module in Services.GetServices<Core.Modularity.IPolyTraderModule>())
module.RegisterUi(uiHost, Services);
ModuleViews.Register(uiHost, Services);
ViewIcons.AssignDefaults(uiHost);
var launcher = new LauncherWindow(uiHost, Services);
+1
View File
@@ -175,6 +175,7 @@ namespace PolyTrader.App.Avalonia
CoreViews.Register(uiHost, host.Services);
foreach (var module in host.Services.GetServices<IPolyTraderModule>())
module.RegisterUi(uiHost, host.Services);
ModuleViews.Register(uiHost, host.Services);
ViewIcons.AssignDefaults(uiHost);
int failures = 0;
@@ -0,0 +1,91 @@
using System;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Modularity;
namespace PolyTrader.App.Avalonia.Shell
{
/// <summary>
/// Registriert die Fenster der Module. Liegt bewusst in der App und nicht in den Modulen
/// Begründung in <c>Views/Modules/README.md</c>: die Modulprojekte bleiben dadurch frei von
/// Avalonia, was für den kopflosen Linux-Betrieb (<c>--headless</c>) den Ausschlag gibt.
///
/// <para>Registriert wird nur, was auch geladen ist: ist ein Modul über
/// <c>DisabledModules</c> abgeschaltet, fehlen seine Dienste im Container dann entfällt auch
/// sein Fenster, und der Launcher zeigt es gar nicht erst an.</para>
/// </summary>
public static class ModuleViews
{
public static void Register(IModuleUiHost host, IServiceProvider services)
{
RegisterIfLoaded(services, "CopyTrading", () => host.RegisterView(new ModuleView
{
Id = "copytrading.main",
Title = "Copytrading",
Group = "CopyTrading",
Order = 100,
CreateView = () => new Views.Modules.CopyTradingWindow(
host,
services.GetRequiredService<PolyTraderSharp.TradingState>(),
services.GetRequiredService<PolyTraderSharp.CopyTradingState>(),
services.GetRequiredService<Modules.CopyTrading.Persistence.ITrackedTraderRepository>(),
services.GetRequiredService<Modules.CopyTrading.Persistence.ICopyTradingAccountSettingsRepository>(),
services.GetRequiredService<Modules.CopyTrading.Persistence.ICopyTradeLogRepository>())
}));
RegisterIfLoaded(services, "ResolutionFarming", () => host.RegisterView(new ModuleView
{
Id = "resolutionfarming.main",
Title = "ResolutionFarming",
Group = "ResolutionFarming",
Order = 200,
CreateView = () => new Views.Modules.ResolutionFarmingWindow(
host,
services.GetRequiredService<PolyTraderSharp.TradingState>(),
services.GetRequiredService<Modules.ResolutionFarming.Persistence.IRfCandidateRepository>(),
services.GetRequiredService<Modules.ResolutionFarming.Persistence.IRfPositionRepository>(),
services.GetRequiredService<Modules.ResolutionFarming.Persistence.IRfClosedTradeRepository>(),
services.GetRequiredService<Modules.ResolutionFarming.Persistence.IRfSettingsRepository>())
}));
RegisterIfLoaded(services, "Supervisor", () => host.RegisterView(new ModuleView
{
Id = "supervisor.main",
Title = "Supervisor",
Group = "Supervisor",
Order = 300,
CreateView = () => new Views.Modules.SupervisorWindow(
host,
services.GetRequiredService<Modules.Supervisor.Agent.SupervisorAgent>(),
services.GetRequiredService<Modules.Supervisor.Services.DossierService>(),
services.GetRequiredService<Modules.Supervisor.Persistence.ISupervisorReportRepository>(),
services.GetRequiredService<Modules.Supervisor.Persistence.ISupervisorCounterfactualRepository>(),
services.GetRequiredService<PolyTraderSharp.Services.TerminalLogger>())
}));
RegisterIfLoaded(services, "Accounting", () => host.RegisterView(new ModuleView
{
Id = "accounting.main",
Title = "Accounting",
Group = "Accounting",
Order = 400,
CreateView = () => new Views.Modules.AccountingWindow(
host,
services.GetRequiredService<PolyTraderSharp.TradingState>(),
services.GetRequiredService<Modules.Accounting.Services.AccountingReportService>(),
services.GetRequiredService<Modules.Accounting.Persistence.ILedgerRepository>(),
services.GetRequiredService<Modules.Accounting.Persistence.IIngestRunRepository>(),
services.GetRequiredService<Modules.Accounting.Services.AccountingIngestService>(),
services.GetRequiredService<PolyTraderSharp.Services.TerminalLogger>())
}));
}
/// <summary>Registriert die Ansicht nur, wenn das Modul in dieser Sitzung geladen ist.</summary>
private static void RegisterIfLoaded(IServiceProvider services, string moduleName, Action register)
{
bool loaded = services.GetServices<IPolyTraderModule>()
.Any(m => string.Equals(m.Name, moduleName, StringComparison.OrdinalIgnoreCase));
if (loaded) register();
}
}
}
@@ -0,0 +1,78 @@
using System;
using Avalonia.Media;
using PolyTrader.Modules.CopyTrading.Logic;
namespace PolyTrader.App.Avalonia.ViewModels
{
/// <summary>
/// Avalonia-Farbpalette zu den <see cref="TradeRowTint"/>-Kategorien. Die Schwellenlogik liegt
/// (getestet) in <see cref="TradeRowColoring"/>; hier steht nur, wie die Kategorien aussehen.
/// Werte unverändert aus der WinForms-Oberfläche übernommen.
/// </summary>
public static class TradeRowPalette
{
public static readonly IBrush Loss = new SolidColorBrush(Color.FromRgb(245, 200, 200)); // rot
public static readonly IBrush SmallWin = new SolidColorBrush(Color.FromRgb(212, 240, 212)); // hellgrün
public static readonly IBrush BigWin = new SolidColorBrush(Color.FromRgb(140, 214, 140)); // grün
public static IBrush ForPnlPercent(decimal pnlPercent) => TradeRowColoring.ForPnlPercent(pnlPercent) switch
{
TradeRowTint.Loss => Loss,
TradeRowTint.BigWin => BigWin,
_ => SmallWin
};
}
/// <summary>Zeile des Grids „Offene Trades" (aufgeloester Kontoname + Zeilenfarbe).</summary>
public sealed class CopyOpenTradeRow
{
public string Account { get; init; } = string.Empty;
public string Trader { get; init; } = string.Empty;
public string Market { get; init; } = string.Empty;
public string Outcome { get; init; } = string.Empty;
public string Side { get; init; } = string.Empty;
public decimal Entry { get; init; }
public decimal Current { get; init; }
public decimal Size { get; init; }
public decimal Amount { get; init; }
public decimal Value { get; init; }
public decimal Unrealized { get; init; }
public decimal UnrealizedPct { get; init; }
public string Status { get; init; } = string.Empty;
/// <summary>Zeilenhintergrund nach Buchgewinn wie zuvor die DataBindingComplete-Färbung.</summary>
public IBrush RowBrush => TradeRowPalette.ForPnlPercent(UnrealizedPct);
}
/// <summary>
/// Zeile des Grids „Geschlossene Trades". Bewusst eigener Typ und nicht das Modell
/// <c>PolyTraderSharp.Models.ClosedTradeRow</c>: hier stehen der aufgeloeste Master-Trader-Name
/// und die Zeilenfarbe, die es dort nicht gibt.
/// </summary>
public sealed class CopyClosedTradeRow
{
public int TradeId { get; init; }
public string Account { get; init; } = string.Empty;
public string Trader { get; init; } = string.Empty;
public string Market { get; init; } = string.Empty;
public string Outcome { get; init; } = string.Empty;
public string Side { get; init; } = string.Empty;
public decimal Entry { get; init; }
public decimal Exit { get; init; }
public decimal Size { get; init; }
public decimal Pnl { get; init; }
public decimal PnlPercent { get; init; }
public DateTime OpenedAt { get; init; }
public DateTime ClosedAt { get; init; }
public IBrush RowBrush => TradeRowPalette.ForPnlPercent(PnlPercent);
}
/// <summary>Eintrag der Account-Zuweisung eines Master-Traders (Mehrfachauswahl).</summary>
public sealed class AccountAssignment
{
public int AccountId { get; init; }
public string Label { get; init; } = string.Empty;
public bool IsAssigned { get; set; }
}
}
@@ -1,4 +1,4 @@
using System;
using System;
namespace PolyTrader.App.Avalonia.ViewModels
{
@@ -33,4 +33,20 @@ namespace PolyTrader.App.Avalonia.ViewModels
/// </summary>
public bool CanToggle { get; init; } = true;
}
/// <summary>
/// Monatszeile der BWA-Übersicht. Die Werte sind bereits in die gewählte Währung umgerechnet
/// und als Text formatiert die Umrechnung passiert einmal beim Aufbau, nicht je Bindung.
/// </summary>
public sealed class MonthlyRow
{
public string Month { get; init; } = string.Empty;
public string Net { get; init; } = string.Empty;
public string Deposits { get; init; } = string.Empty;
public string Withdrawals { get; init; } = string.Empty;
public string Fees { get; init; } = string.Empty;
public string Volume { get; init; } = string.Empty;
public int Trades { get; init; }
public string Closing { get; init; } = string.Empty;
}
}
@@ -1,4 +1,4 @@
using Avalonia.Media;
using Avalonia.Media;
namespace PolyTrader.App.Avalonia.ViewModels
{
@@ -20,4 +20,12 @@ namespace PolyTrader.App.Avalonia.ViewModels
public string Cid { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
}
/// <summary>Ein Eintrag im Supervisor-Analyseverlauf (Frage oder Antwort).</summary>
public sealed class ChatEntry
{
public string Header { get; init; } = string.Empty;
public string Text { get; init; } = string.Empty;
public IBrush HeaderBrush { get; init; } = Brushes.SteelBlue;
}
}
@@ -0,0 +1,167 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:acc="using:PolyTrader.Modules.Accounting.Models"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
x:Class="PolyTrader.App.Avalonia.Views.Modules.AccountingWindow"
Title="Accounting"
Width="1571" Height="1067">
<!-- Drei Tabs (Übersicht/BWA, Ledger, Abruf/Status), Aufbau nach
docs/UI-SPEZIFIKATION-WinForms.md. KPI-Kacheln zweizeilig wie zuvor. -->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Name="lblStatus" Text="Bereit." />
</Border>
<TabControl>
<TabItem Header="Übersicht / BWA">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Konto:" VerticalAlignment="Center" />
<ComboBox Name="cbAccount" MinWidth="220" />
<TextBlock Text="Von:" VerticalAlignment="Center" />
<CalendarDatePicker Name="dtFrom" MinWidth="140" />
<TextBlock Text="Bis:" VerticalAlignment="Center" />
<CalendarDatePicker Name="dtTo" MinWidth="140" />
<TextBlock Text="Währung:" VerticalAlignment="Center" />
<ComboBox Name="cbCurrency" MinWidth="100" />
<Button Name="btnCompute" Content="Berechnen" />
<Button Name="btnPdf" Content="PDF-Export" />
<Button Name="btnCsv" Content="CSV-Export" />
</StackPanel>
</Border>
<DockPanel>
<WrapPanel DockPanel.Dock="Top" Margin="12" Orientation="Horizontal">
<Border Classes="kpi">
<StackPanel>
<TextBlock Classes="caption" Text="Netto-Handelsergebnis" />
<TextBlock Name="kpiNet" Classes="value" Text="—" />
</StackPanel>
</Border>
<Border Classes="kpi">
<StackPanel>
<TextBlock Classes="caption" Text="Endsaldo" />
<TextBlock Name="kpiClosing" Classes="value" Text="—" />
</StackPanel>
</Border>
<Border Classes="kpi">
<StackPanel>
<TextBlock Classes="caption" Text="Einzahlungen" />
<TextBlock Name="kpiDeposits" Classes="value" Text="—" />
</StackPanel>
</Border>
<Border Classes="kpi">
<StackPanel>
<TextBlock Classes="caption" Text="Auszahlungen" />
<TextBlock Name="kpiWithdrawals" Classes="value" Text="—" />
</StackPanel>
</Border>
<Border Classes="kpi">
<StackPanel>
<TextBlock Classes="caption" Text="Fees" />
<TextBlock Name="kpiFees" Classes="value" Text="—" />
</StackPanel>
</Border>
<Border Classes="kpi">
<StackPanel>
<TextBlock Classes="caption" Text="Rewards" />
<TextBlock Name="kpiRewards" Classes="value" Text="—" />
</StackPanel>
</Border>
<Border Classes="kpi">
<StackPanel>
<TextBlock Classes="caption" Text="Handelsvolumen" />
<TextBlock Name="kpiVolume" Classes="value" Text="—" />
</StackPanel>
</Border>
<Border Classes="kpi">
<StackPanel>
<TextBlock Classes="caption" Text="Trades" />
<TextBlock Name="kpiTrades" Classes="value" Text="—" />
</StackPanel>
</Border>
</WrapPanel>
<TextBlock DockPanel.Dock="Top" Name="lblCurrencyNote" Margin="12,0"
Foreground="#777777" FontSize="11" TextWrapping="Wrap" />
<DataGrid Name="gridMonthly" Margin="12" x:DataType="vm:MonthlyRow">
<DataGrid.Columns>
<DataGridTextColumn Header="Monat" Width="140" Binding="{Binding Month}" />
<DataGridTextColumn Header="Handelsergebnis" Width="160" Binding="{Binding Net}" />
<DataGridTextColumn Header="Einzahlungen" Width="140" Binding="{Binding Deposits}" />
<DataGridTextColumn Header="Auszahlungen" Width="140" Binding="{Binding Withdrawals}" />
<DataGridTextColumn Header="Fees" Width="120" Binding="{Binding Fees}" />
<DataGridTextColumn Header="Volumen" Width="140" Binding="{Binding Volume}" />
<DataGridTextColumn Header="Trades" Width="90" Binding="{Binding Trades}" />
<DataGridTextColumn Header="Endsaldo" Width="*" Binding="{Binding Closing}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</DockPanel>
</TabItem>
<TabItem Header="Ledger">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Konto:" VerticalAlignment="Center" />
<ComboBox Name="cbLedgerAccount" MinWidth="220" />
<Button Name="btnLedgerRefresh" Content="Aktualisieren" />
</StackPanel>
</Border>
<DataGrid Name="gridLedger" x:DataType="acc:LedgerEntry">
<DataGrid.Columns>
<DataGridTextColumn Header="Zeitpunkt" Width="150" Binding="{Binding Timestamp, StringFormat=\{0:dd.MM.yyyy HH:mm\}}" />
<DataGridTextColumn Header="Typ" Width="120" Binding="{Binding EventType}" />
<DataGridTextColumn Header="Markt" Width="*" Binding="{Binding MarketSlug}" />
<DataGridTextColumn Header="Outcome" Width="80" Binding="{Binding Outcome}" />
<DataGridTextColumn Header="Side" Width="60" Binding="{Binding Side}" />
<DataGridTextColumn Header="Size" Width="90" Binding="{Binding Size, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="Preis" Width="80" Binding="{Binding PriceUsdc, StringFormat=\{0:F3\}}" />
<DataGridTextColumn Header="Brutto" Width="100" Binding="{Binding GrossUsdc, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="Fee" Width="90" Binding="{Binding FeeUsdc, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="Netto" Width="100" Binding="{Binding NetUsdc, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="Quelle" Width="110" Binding="{Binding Source}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
<TabItem Header="Abruf / Status">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Konto:" VerticalAlignment="Center" />
<ComboBox Name="cbStatusAccount" MinWidth="220" />
<Button Name="btnIngestNow" Content="Inkrement jetzt" />
<Button Name="btnBackfill" Content="Backfill (volle Historie)" />
<Button Name="btnStatusRefresh" Content="Aktualisieren" />
</StackPanel>
</Border>
<DataGrid Name="gridRuns" x:DataType="acc:IngestRun">
<DataGrid.Columns>
<DataGridTextColumn Header="Start" Width="160" Binding="{Binding StartedAt, StringFormat=\{0:dd.MM.yyyy HH:mm:ss\}}" />
<DataGridTextColumn Header="Ende" Width="160" Binding="{Binding FinishedAt, StringFormat=\{0:dd.MM.yyyy HH:mm:ss\}}" />
<DataGridCheckBoxColumn Header="Backfill" Width="80" Binding="{Binding Backfill}" />
<DataGridCheckBoxColumn Header="Erfolg" Width="70" Binding="{Binding Success}" />
<DataGridTextColumn Header="Neu" Width="70" Binding="{Binding NewEntries}" />
<DataGridTextColumn Header="Duplikate" Width="90" Binding="{Binding DuplicateEntries}" />
<DataGridTextColumn Header="Meldung" Width="*" Binding="{Binding Message}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
</TabControl>
</DockPanel>
</Window>
@@ -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;
}
}
}
@@ -0,0 +1,161 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
xmlns:ct="using:PolyTraderSharp.Models"
x:Class="PolyTrader.App.Avalonia.Views.Modules.CopyTradingWindow"
Title="Copytrading"
Width="1714" Height="1167">
<!--
Ein Fenster fürs ganze Modul, vier Tabs Aufbau nach docs/UI-SPEZIFIKATION-WinForms.md.
Spaltenreihenfolge und -überschriften entsprechen der bisherigen Oberfläche; feste
Pixelbreiten sind in Sternbreiten übersetzt, damit das Fenster mitwächst.
-->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<TabControl>
<TabItem Header="Master-Trader">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal">
<Button Name="btnTraderRefresh" Content="Aktualisieren" />
<Button Name="btnTraderSave" Content="Speichern" />
<TextBlock Name="lblTraderHint" VerticalAlignment="Center"
Margin="12,0,0,0" Foreground="#777777" />
</StackPanel>
</Border>
<Grid ColumnDefinitions="*,4,460">
<DataGrid Name="gridTraders" Grid.Column="0" x:DataType="ct:TrackedTrader">
<DataGrid.Columns>
<DataGridTextColumn Header="#" Width="50" Binding="{Binding Id}" />
<DataGridTextColumn Header="Wallet" Width="170" Binding="{Binding WalletAddress}" />
<DataGridTextColumn Header="Name" Width="*" Binding="{Binding DisplayName}" />
<DataGridTextColumn Header="Kategorie" Width="110" Binding="{Binding Category}" />
<DataGridCheckBoxColumn Header="Aktiv" Width="50" Binding="{Binding IsActive}" />
<DataGridTextColumn Header="Trades (7T)" Width="90" Binding="{Binding TotalTrades}" />
<DataGridTextColumn Header="Winrate %" Width="90" Binding="{Binding Winrate30t, StringFormat=\{0:F1\}}" />
<DataGridTextColumn Header="PnL (7T)" Width="100" Binding="{Binding TotalPnl, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="Copy-PnL (30T)" Width="110" Binding="{Binding CopyPnl30d, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="Profit-Faktor" Width="100" Binding="{Binding CopyProfitFactor, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="Ø PnL/Trade" Width="100" Binding="{Binding CopyAvgPnlPerTrade, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="Copy-Trades" Width="90" Binding="{Binding CopyTradeCount30d}" />
</DataGrid.Columns>
</DataGrid>
<GridSplitter Grid.Column="1" Background="#DDDDDD" />
<DockPanel Grid.Column="2">
<HeaderedContentControl DockPanel.Dock="Bottom" Header="Zugewiesene Accounts"
Margin="8" Height="220">
<ListBox Name="listAccounts">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:AccountAssignment">
<CheckBox IsChecked="{Binding IsAssigned, Mode=TwoWay}"
Content="{Binding Label}" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</HeaderedContentControl>
<controls:SettingsEditor Name="editorTrader" />
</DockPanel>
</Grid>
</DockPanel>
</TabItem>
<TabItem Header="Offene Trades">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal">
<Button Name="btnOpenRefresh" Content="Aktualisieren" />
</StackPanel>
</Border>
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Name="lblOpenSummary" Text="—" />
</Border>
<DataGrid Name="gridOpen" x:DataType="vm:CopyOpenTradeRow">
<DataGrid.Columns>
<DataGridTextColumn Header="Account" Width="120" Binding="{Binding Account}" />
<DataGridTextColumn Header="Master-Trader" Width="120" Binding="{Binding Trader}" />
<DataGridTextColumn Header="Markt" Width="*" Binding="{Binding Market}" />
<DataGridTextColumn Header="Outcome" Width="80" Binding="{Binding Outcome}" />
<DataGridTextColumn Header="Side" Width="55" Binding="{Binding Side}" />
<DataGridTextColumn Header="Entry" Width="65" Binding="{Binding Entry, StringFormat=\{0:F3\}}" />
<DataGridTextColumn Header="Aktuell" Width="65" Binding="{Binding Current, StringFormat=\{0:F3\}}" />
<DataGridTextColumn Header="Size" Width="70" Binding="{Binding Size, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="Einsatz" Width="80" Binding="{Binding Amount, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="Wert" Width="80" Binding="{Binding Value, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="Buchgewinn" Width="90" Binding="{Binding Unrealized, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="Buchgew. %" Width="80" Binding="{Binding UnrealizedPct, StringFormat=\{0:F1\}}" />
<DataGridTextColumn Header="Status" Width="90" Binding="{Binding Status}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
<TabItem Header="Geschlossene Trades">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Name="btnClosedRefresh" Content="Aktualisieren" />
<TextBlock Text="Markt:" VerticalAlignment="Center" />
<TextBox Name="tbClosedMarket" MinWidth="200" Watermark="Suche …" />
<TextBlock Text="Master:" VerticalAlignment="Center" />
<ComboBox Name="cbClosedMaster" MinWidth="160" />
<TextBlock Text="Ergebnis:" VerticalAlignment="Center" />
<ComboBox Name="cbClosedResult" MinWidth="120" />
<TextBlock Text="Von:" VerticalAlignment="Center" />
<CalendarDatePicker Name="dtClosedFrom" MinWidth="140" />
<TextBlock Text="Bis:" VerticalAlignment="Center" />
<CalendarDatePicker Name="dtClosedTo" MinWidth="140" />
<Button Name="btnClosedReset" Content="Zurücksetzen" />
</StackPanel>
</Border>
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Name="lblClosedSummary" Text="—" />
</Border>
<DataGrid Name="gridClosed" x:DataType="vm:CopyClosedTradeRow">
<DataGrid.Columns>
<DataGridTextColumn Header="#" Width="60" Binding="{Binding TradeId}" />
<DataGridTextColumn Header="Account" Width="130" Binding="{Binding Account}" />
<DataGridTextColumn Header="Master-Trader" Width="130" Binding="{Binding Trader}" />
<DataGridTextColumn Header="Markt" Width="*" Binding="{Binding Market}" />
<DataGridTextColumn Header="Outcome" Width="80" Binding="{Binding Outcome}" />
<DataGridTextColumn Header="Side" Width="60" Binding="{Binding Side}" />
<DataGridTextColumn Header="Entry" Width="70" Binding="{Binding Entry, StringFormat=\{0:F3\}}" />
<DataGridTextColumn Header="Exit" Width="70" Binding="{Binding Exit, StringFormat=\{0:F3\}}" />
<DataGridTextColumn Header="Size" Width="70" Binding="{Binding Size, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="PnL" Width="80" Binding="{Binding Pnl, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="PnL %" Width="70" Binding="{Binding PnlPercent, StringFormat=\{0:F1\}}" />
<DataGridTextColumn Header="Eröffnet" Width="130" Binding="{Binding OpenedAt, StringFormat=\{0:dd.MM.yyyy HH:mm\}}" />
<DataGridTextColumn Header="Geschlossen" Width="130" Binding="{Binding ClosedAt, StringFormat=\{0:dd.MM.yyyy HH:mm\}}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
<TabItem Header="Account-Einstellungen">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Konto:" VerticalAlignment="Center" />
<ComboBox Name="cbSettingsAccount" MinWidth="240" />
<Button Name="btnSettingsSave" Content="Speichern" />
<TextBlock Name="lblSettingsHint" VerticalAlignment="Center" Foreground="#777777" />
</StackPanel>
</Border>
<controls:SettingsEditor Name="editorAccount" />
</DockPanel>
</TabItem>
</TabControl>
</DockPanel>
</Window>
@@ -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;
}
}
}
@@ -0,0 +1,18 @@
# Modul-Fenster
Die Fenster der Module liegen bewusst **hier in der App** und nicht in den Modulprojekten.
**Warum:** Die App ist der Kompositionswurzel sie referenziert ohnehin alle vier Module. Damit
bleiben die Modulprojekte (Trading-Engine, Scanner, Supervisor, Accounting) **frei von Avalonia**.
Das ist für den kopflosen Linux-Betrieb (`--headless`) der entscheidende Punkt: der Daemon soll
keine GUI-Bibliothek mitschleppen. Zusätzlich können die Fenster so die gemeinsamen Bausteine der
Shell nutzen (`WindowMenuBar`, `SettingsEditor`, `DialogWindow`), ohne dass diese in eine eigene
Bibliothek ausgelagert werden müssen.
**Folge:** `IPolyTraderModule.RegisterUi` bleibt je Modul ein No-Op; registriert werden die Ansichten
in `Shell/ModuleViews.cs`. Die stabilen View-IDs (`copytrading.main` usw.) bleiben unverändert,
sodass Symbole, Reihenfolge und Launcher-Verhalten identisch sind.
**Alternative, falls Module später doch ihre UI selbst mitbringen sollen:** eine gemeinsame
Bibliothek `PolyTrader.Ui.Shared` mit den obigen Bausteinen anlegen und die Fenster in
`PolyTrader.Modules.X.Ui`-Projekte verschieben. Der Aufbau der Fenster bliebe dabei unverändert.
@@ -0,0 +1,97 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:rf="using:PolyTrader.Modules.ResolutionFarming.Models"
x:Class="PolyTrader.App.Avalonia.Views.Modules.ResolutionFarmingWindow"
Title="ResolutionFarming"
Width="1429" Height="1030">
<!-- Vier Tabs (Kandidaten / Positionen / Historie+Statistik / Settings), Aufbau nach
docs/UI-SPEZIFIKATION-WinForms.md: je Tab ein Grid mit „Aktualisieren", Konto-Auswahl oben. -->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Konto:" VerticalAlignment="Center" />
<ComboBox Name="cbAccount" MinWidth="240" />
<Button Name="btnRefresh" Content="Aktualisieren" />
<TextBlock Name="lblStatus" VerticalAlignment="Center" Foreground="#777777" />
</StackPanel>
</Border>
<TabControl>
<TabItem Header="Kandidaten">
<DataGrid Name="gridCandidates" x:DataType="rf:RfCandidate">
<DataGrid.Columns>
<DataGridTextColumn Header="Gescannt" Width="140" Binding="{Binding ScannedAt, StringFormat=\{0:dd.MM. HH:mm\}}" />
<DataGridTextColumn Header="Markt" Width="*" Binding="{Binding MarketQuestion}" />
<DataGridTextColumn Header="Outcome" Width="80" Binding="{Binding Outcome}" />
<DataGridTextColumn Header="Kategorie" Width="120" Binding="{Binding Category}" />
<DataGridTextColumn Header="Ask" Width="70" Binding="{Binding Ask, StringFormat=\{0:F3\}}" />
<DataGridTextColumn Header="Fee (bps)" Width="80" Binding="{Binding FeeBps}" />
<DataGridTextColumn Header="Netto-Edge %" Width="110" Binding="{Binding NetEdgePct, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="Score" Width="80" Binding="{Binding Score, StringFormat=\{0:F2\}}" />
</DataGrid.Columns>
</DataGrid>
</TabItem>
<TabItem Header="Positionen">
<DockPanel>
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Name="lblPositionsSummary" Text="—" />
</Border>
<DataGrid Name="gridPositions" x:DataType="rf:RfPosition">
<DataGrid.Columns>
<DataGridTextColumn Header="Eröffnet" Width="140" Binding="{Binding OpenedAt, StringFormat=\{0:dd.MM.yyyy HH:mm\}}" />
<DataGridTextColumn Header="Markt" Width="*" Binding="{Binding MarketQuestion}" />
<DataGridTextColumn Header="Outcome" Width="80" Binding="{Binding Outcome}" />
<DataGridTextColumn Header="Kategorie" Width="120" Binding="{Binding Category}" />
<DataGridTextColumn Header="Entry" Width="70" Binding="{Binding EntryPrice, StringFormat=\{0:F3\}}" />
<DataGridTextColumn Header="Size" Width="80" Binding="{Binding Size, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="Einsatz" Width="90" Binding="{Binding AmountUsd, StringFormat=\{0:F2\}}" />
<DataGridCheckBoxColumn Header="Demo" Width="60" Binding="{Binding IsDemo}" />
<DataGridTextColumn Header="Auflösung" Width="140" Binding="{Binding EndDate, StringFormat=\{0:dd.MM.yyyy\}}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
<TabItem Header="Historie / Statistik">
<DockPanel>
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Name="lblHistorySummary" Text="—" />
</Border>
<DataGrid Name="gridHistory" x:DataType="rf:RfClosedTrade">
<DataGrid.Columns>
<DataGridTextColumn Header="Markt" Width="*" Binding="{Binding MarketQuestion}" />
<DataGridTextColumn Header="Outcome" Width="80" Binding="{Binding Outcome}" />
<DataGridTextColumn Header="Kategorie" Width="120" Binding="{Binding Category}" />
<DataGridTextColumn Header="Entry" Width="70" Binding="{Binding EntryPrice, StringFormat=\{0:F3\}}" />
<DataGridTextColumn Header="Exit" Width="70" Binding="{Binding ExitPrice, StringFormat=\{0:F3\}}" />
<DataGridTextColumn Header="Size" Width="80" Binding="{Binding Size, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="PnL" Width="90" Binding="{Binding RealizedPnl, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="PnL %" Width="80" Binding="{Binding PnlPercent, StringFormat=\{0:F1\}}" />
<DataGridCheckBoxColumn Header="Demo" Width="60" Binding="{Binding IsDemo}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
<TabItem Header="Settings">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Name="btnSettingsSave" Content="Speichern" />
<TextBlock Name="lblSettingsHint" VerticalAlignment="Center" Foreground="#777777" />
</StackPanel>
</Border>
<controls:SettingsEditor Name="editorSettings" />
</DockPanel>
</TabItem>
</TabControl>
</DockPanel>
</Window>
@@ -0,0 +1,127 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using PolyTrader.App.Avalonia.Controls;
using PolyTrader.Core.Modularity;
using PolyTrader.Modules.ResolutionFarming.Models;
using PolyTrader.Modules.ResolutionFarming.Persistence;
using PolyTraderSharp;
namespace PolyTrader.App.Avalonia.Views.Modules
{
/// <summary>
/// ResolutionFarming: Kandidaten, offene Positionen, Historie und Einstellungen je Konto.
/// Layout vollständig in ResolutionFarmingWindow.axaml.
/// </summary>
public partial class ResolutionFarmingWindow : Window
{
private readonly ObservableCollection<RfCandidate> _candidates = new();
private readonly ObservableCollection<RfPosition> _positions = new();
private readonly ObservableCollection<RfClosedTrade> _history = new();
private readonly TradingState _state;
private readonly IRfCandidateRepository _candidateRepo;
private readonly IRfPositionRepository _positionRepo;
private readonly IRfClosedTradeRepository _closedRepo;
private readonly IRfSettingsRepository _settingsRepo;
private RfSettings? _currentSettings;
public ResolutionFarmingWindow() => AvaloniaXamlLoader.Load(this);
public ResolutionFarmingWindow(IModuleUiHost host, TradingState state,
IRfCandidateRepository candidateRepo, IRfPositionRepository positionRepo,
IRfClosedTradeRepository closedRepo, IRfSettingsRepository settingsRepo) : this()
{
this.FindControl<WindowMenuBar>("menuBar")!.Attach(host, "resolutionfarming.main", this);
_state = state;
_candidateRepo = candidateRepo;
_positionRepo = positionRepo;
_closedRepo = closedRepo;
_settingsRepo = settingsRepo;
this.FindControl<DataGrid>("gridCandidates")!.ItemsSource = _candidates;
this.FindControl<DataGrid>("gridPositions")!.ItemsSource = _positions;
this.FindControl<DataGrid>("gridHistory")!.ItemsSource = _history;
this.FindControl<ComboBox>("cbAccount")!.SelectionChanged += (_, _) => LoadAll();
this.FindControl<Button>("btnRefresh")!.Click += (_, _) => LoadAll();
this.FindControl<Button>("btnSettingsSave")!.Click += (_, _) => SaveSettings();
LoadAccounts();
}
private void LoadAccounts()
{
var cb = this.FindControl<ComboBox>("cbAccount")!;
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; else Status("Keine Konten vorhanden.");
}
private int? SelectedAccountId =>
(this.FindControl<ComboBox>("cbAccount")!.SelectedItem as AccountChoice)?.Id;
private void Status(string text) => this.FindControl<TextBlock>("lblStatus")!.Text = text;
private void LoadAll()
{
if (SelectedAccountId is not int accountId) return;
try
{
_candidates.Clear();
foreach (var c in _candidateRepo.GetRecent(accountId, 500)) _candidates.Add(c);
_positions.Clear();
foreach (var p in _positionRepo.GetOpen(accountId).OrderByDescending(p => p.OpenedAt)) _positions.Add(p);
_history.Clear();
foreach (var t in _closedRepo.Find(t => t.AccountId == accountId).OrderByDescending(t => t.Id))
_history.Add(t);
this.FindControl<TextBlock>("lblPositionsSummary")!.Text =
$"{_positions.Count} offene Position(en) · Einsatz {_positions.Sum(p => p.AmountUsd):N2} USDC";
decimal pnl = _history.Sum(t => t.RealizedPnl);
int wins = _history.Count(t => t.RealizedPnl > 0m);
decimal winrate = _history.Count > 0 ? 100m * wins / _history.Count : 0m;
this.FindControl<TextBlock>("lblHistorySummary")!.Text =
$"{_history.Count} abgeschlossen · PnL {pnl:N2} USDC · Winrate {winrate:N1} %";
_currentSettings = _settingsRepo.Get(accountId) ?? new RfSettings { AccountId = accountId };
this.FindControl<SettingsEditor>("editorSettings")!.Show(_currentSettings);
Status($"Geladen um {DateTime.Now:HH:mm:ss}.");
}
catch (Exception ex)
{
Status($"Laden fehlgeschlagen: {ex.Message}");
}
}
private void SaveSettings()
{
if (_currentSettings == null) return;
try
{
_settingsRepo.Upsert(_currentSettings);
this.FindControl<TextBlock>("lblSettingsHint")!.Text =
$"Gespeichert für Konto #{_currentSettings.AccountId} um {DateTime.Now:HH:mm:ss}.";
}
catch (Exception ex)
{
this.FindControl<TextBlock>("lblSettingsHint")!.Text = $"Speichern fehlgeschlagen: {ex.Message}";
}
}
private sealed record AccountChoice(int Id, string Label)
{
public override string ToString() => Label;
}
}
}
@@ -0,0 +1,129 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
xmlns:sup="using:PolyTrader.Modules.Supervisor.Persistence"
xmlns:cf="using:PolyTrader.Modules.Supervisor.Counterfactual"
xmlns:svc="using:PolyTrader.Modules.Supervisor.Services"
x:Class="PolyTrader.App.Avalonia.Views.Modules.SupervisorWindow"
Title="Supervisor"
Width="1786" Height="1163">
<!-- Vier Tabs (Analyse / Dossiers / Berichte / Counterfactual), Aufbau nach
docs/UI-SPEZIFIKATION-WinForms.md. Der Analyse-Tab ist chat-artig: Verlauf oben,
Eingabe unten, Profil- und Modellauswahl in der Werkzeugleiste. -->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<TabControl>
<TabItem Header="Analyse">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Profil:" VerticalAlignment="Center" />
<ComboBox Name="cbProfile" MinWidth="180" />
<TextBlock Text="Modell:" VerticalAlignment="Center" />
<TextBox Name="tbModel" MinWidth="240" Watermark="(Standardmodell)" />
<Button Name="btnClearChat" Content="Verlauf leeren" />
<TextBlock Name="lblChatStatus" VerticalAlignment="Center" Foreground="#777777" />
</StackPanel>
</Border>
<Border Classes="toolbar" DockPanel.Dock="Bottom">
<DockPanel>
<Button Name="btnSend" Content="Senden" DockPanel.Dock="Right" MinWidth="100" />
<TextBox Name="tbQuestion" Watermark="Frage an den Supervisor …"
AcceptsReturn="False" Margin="0,0,8,0" />
</DockPanel>
</Border>
<ScrollViewer Name="chatScroll">
<ItemsControl Name="chatLines" Margin="12">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ChatEntry">
<StackPanel Margin="0,0,0,12">
<TextBlock Text="{Binding Header}" FontWeight="SemiBold"
Foreground="{Binding HeaderBrush}" />
<SelectableTextBlock Text="{Binding Text}" TextWrapping="Wrap" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</TabItem>
<TabItem Header="Dossiers">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Name="btnDossierRefresh" Content="Aktualisieren" />
<Button Name="btnDossierOpen" Content="Dossier öffnen" />
<TextBlock Text="SignalId:" VerticalAlignment="Center" />
<TextBox Name="tbSignalId" MinWidth="240" />
</StackPanel>
</Border>
<Grid ColumnDefinitions="*,4,*">
<DataGrid Name="gridSignals" Grid.Column="0" x:DataType="svc:SignalSummary">
<DataGrid.Columns>
<DataGridTextColumn Header="Zeit" Width="130" Binding="{Binding FirstSeen, StringFormat=\{0:dd.MM. HH:mm\}}" />
<DataGridTextColumn Header="Modul" Width="110" Binding="{Binding ModuleName}" />
<DataGridTextColumn Header="Markt" Width="*" Binding="{Binding MarketQuestion}" />
<DataGridTextColumn Header="Side" Width="60" Binding="{Binding Side}" />
<DataGridTextColumn Header="Entscheidung" Width="120" Binding="{Binding LastDecision}" />
</DataGrid.Columns>
</DataGrid>
<GridSplitter Grid.Column="1" Background="#DDDDDD" />
<ScrollViewer Grid.Column="2">
<SelectableTextBlock Name="txtDossier" Margin="12" TextWrapping="Wrap"
FontFamily="Consolas,Menlo,DejaVu Sans Mono,monospace"
FontSize="12" />
</ScrollViewer>
</Grid>
</DockPanel>
</TabItem>
<TabItem Header="Berichte">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<Button Name="btnReportsRefresh" Content="Aktualisieren" />
</Border>
<DataGrid Name="gridReports" x:DataType="sup:SupervisorReport">
<DataGrid.Columns>
<DataGridTextColumn Header="Erstellt" Width="150" Binding="{Binding CreatedAt, StringFormat=\{0:dd.MM.yyyy HH:mm\}}" />
<DataGridTextColumn Header="Profil" Width="140" Binding="{Binding Profile}" />
<DataGridTextColumn Header="Modell" Width="200" Binding="{Binding Model}" />
<DataGridTextColumn Header="Frage" Width="300" Binding="{Binding Question}" />
<DataGridTextColumn Header="Antwort" Width="*" Binding="{Binding Answer}" />
<DataGridTextColumn Header="Tools" Width="70" Binding="{Binding ToolCallCount}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
<TabItem Header="Counterfactual">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<Button Name="btnCfRefresh" Content="Aktualisieren" />
</Border>
<DataGrid Name="gridCounterfactual" x:DataType="cf:CounterfactualRecord">
<DataGrid.Columns>
<DataGridTextColumn Header="Geprüft" Width="150" Binding="{Binding CheckedAt, StringFormat=\{0:dd.MM.yyyy HH:mm\}}" />
<DataGridTextColumn Header="Modul" Width="120" Binding="{Binding ModuleName}" />
<DataGridTextColumn Header="Markt" Width="*" Binding="{Binding MarketQuestion}" />
<DataGridTextColumn Header="Grund der Ablehnung" Width="260" Binding="{Binding Reason}" />
<DataGridTextColumn Header="Signalpreis" Width="100" Binding="{Binding SignalPrice, StringFormat=\{0:F3\}}" />
<DataGridCheckBoxColumn Header="Wäre Gewinner" Width="120" Binding="{Binding IsWinner}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
</TabControl>
</DockPanel>
</Window>
@@ -0,0 +1,192 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Threading;
using PolyTrader.App.Avalonia.ViewModels;
using PolyTrader.Core.Analytics;
using PolyTrader.Core.Modularity;
using PolyTrader.Modules.Supervisor.Agent;
using PolyTrader.Modules.Supervisor.Counterfactual;
using PolyTrader.Modules.Supervisor.Persistence;
using PolyTrader.Modules.Supervisor.Services;
using PolyTraderSharp.Services;
namespace PolyTrader.App.Avalonia.Views.Modules
{
/// <summary>
/// Supervisor: Analyse-Chat gegen den Agenten, Dossiers je Signal, gespeicherte Berichte und
/// Counterfactuals. Layout vollständig in SupervisorWindow.axaml.
/// </summary>
public partial class SupervisorWindow : Window
{
private readonly ObservableCollection<ChatEntry> _chat = new();
private readonly ObservableCollection<SignalSummary> _signals = new();
private readonly ObservableCollection<SupervisorReport> _reports = new();
private readonly ObservableCollection<CounterfactualRecord> _counterfactuals = new();
private readonly SupervisorAgent _agent = null!;
private readonly DossierService _dossiers = null!;
private readonly ISupervisorReportRepository _reportRepo = null!;
private readonly ISupervisorCounterfactualRepository _cfRepo = null!;
private readonly TerminalLogger _logger = null!;
public SupervisorWindow() => AvaloniaXamlLoader.Load(this);
public SupervisorWindow(IModuleUiHost host, SupervisorAgent agent, DossierService dossiers,
ISupervisorReportRepository reportRepo, ISupervisorCounterfactualRepository cfRepo,
TerminalLogger logger) : this()
{
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "supervisor.main", this);
_agent = agent;
_dossiers = dossiers;
_reportRepo = reportRepo;
_cfRepo = cfRepo;
_logger = logger;
this.FindControl<ItemsControl>("chatLines")!.ItemsSource = _chat;
this.FindControl<DataGrid>("gridSignals")!.ItemsSource = _signals;
this.FindControl<DataGrid>("gridReports")!.ItemsSource = _reports;
this.FindControl<DataGrid>("gridCounterfactual")!.ItemsSource = _counterfactuals;
var cbProfile = this.FindControl<ComboBox>("cbProfile")!;
cbProfile.ItemsSource = SupervisorProfiles.All.ToList();
cbProfile.SelectedIndex = 0;
this.FindControl<Button>("btnSend")!.Click += async (_, _) => await AskAsync();
this.FindControl<Button>("btnClearChat")!.Click += (_, _) => _chat.Clear();
this.FindControl<Button>("btnDossierRefresh")!.Click += (_, _) => LoadSignals();
this.FindControl<Button>("btnDossierOpen")!.Click += (_, _) => OpenDossier();
this.FindControl<Button>("btnReportsRefresh")!.Click += (_, _) => LoadReports();
this.FindControl<Button>("btnCfRefresh")!.Click += (_, _) => LoadCounterfactuals();
// Auswahl im Signal-Grid übernimmt die SignalId ins Eingabefeld.
this.FindControl<DataGrid>("gridSignals")!.SelectionChanged += (_, _) =>
{
if (this.FindControl<DataGrid>("gridSignals")!.SelectedItem is SignalSummary s)
this.FindControl<TextBox>("tbSignalId")!.Text = s.SignalId;
};
LoadSignals();
LoadReports();
LoadCounterfactuals();
}
// ===== Analyse =====
private async System.Threading.Tasks.Task AskAsync()
{
var tbQuestion = this.FindControl<TextBox>("tbQuestion")!;
string question = tbQuestion.Text?.Trim() ?? string.Empty;
if (question.Length == 0) return;
var btn = this.FindControl<Button>("btnSend")!;
var status = this.FindControl<TextBlock>("lblChatStatus")!;
AddChat("Du", question, Brushes.SteelBlue);
tbQuestion.Text = string.Empty;
btn.IsEnabled = false;
status.Text = "Der Supervisor denkt nach …";
try
{
var profile = this.FindControl<ComboBox>("cbProfile")!.SelectedItem as SupervisorProfile;
string? model = this.FindControl<TextBox>("tbModel")!.Text?.Trim();
if (string.IsNullOrWhiteSpace(model)) model = null;
// Zwischenmeldungen des Agenten (Tool-Aufrufe) laufen über die Statuszeile.
var progress = new Progress<string>(m => Dispatcher.UIThread.Post(() => status.Text = m));
var result = await _agent.AskAsync(question, model, progress, profile);
AddChat($"Supervisor ({profile?.Name ?? "Allgemein"})", result.Answer, Brushes.SeaGreen);
status.Text = $"{result.ToolInvocations.Count} Tool-Aufruf(e) · " +
$"{result.PromptTokens + result.CompletionTokens} Token.";
}
catch (Exception ex)
{
AddChat("Fehler", ex.Message, Brushes.Firebrick);
status.Text = "Anfrage fehlgeschlagen.";
_logger.Error($"Supervisor-Anfrage fehlgeschlagen: {ex.Message}");
}
finally
{
btn.IsEnabled = true;
LoadReports(); // die Anfrage wurde als Bericht abgelegt
}
}
private void AddChat(string who, string text, IBrush brush)
{
_chat.Add(new ChatEntry { Header = $"[{DateTime.Now:HH:mm:ss}] {who}", Text = text, HeaderBrush = brush });
this.FindControl<ScrollViewer>("chatScroll")!.ScrollToEnd();
}
// ===== Dossiers =====
private void LoadSignals()
{
try
{
_signals.Clear();
foreach (var s in _dossiers.RecentSignals(200)) _signals.Add(s);
}
catch (Exception ex)
{
_logger.Error($"Signale konnten nicht geladen werden: {ex.Message}");
}
}
private void OpenDossier()
{
string signalId = this.FindControl<TextBox>("tbSignalId")!.Text?.Trim() ?? string.Empty;
var target = this.FindControl<SelectableTextBlock>("txtDossier")!;
if (signalId.Length == 0)
{
target.Text = "Bitte eine SignalId auswählen oder eingeben.";
return;
}
try
{
var dossier = _dossiers.BuildForSignal(signalId);
target.Text = DossierBuilder.ToMarkdown(dossier);
}
catch (Exception ex)
{
target.Text = $"Dossier konnte nicht erstellt werden: {ex.Message}";
}
}
// ===== Berichte / Counterfactual =====
private void LoadReports()
{
try
{
_reports.Clear();
foreach (var r in _reportRepo.GetRecent(200)) _reports.Add(r);
}
catch (Exception ex)
{
_logger.Error($"Berichte konnten nicht geladen werden: {ex.Message}");
}
}
private void LoadCounterfactuals()
{
try
{
_counterfactuals.Clear();
foreach (var c in _cfRepo.GetRecent(500)) _counterfactuals.Add(c);
}
catch (Exception ex)
{
_logger.Error($"Counterfactuals konnten nicht geladen werden: {ex.Message}");
}
}
}
}