L1a: Core und Module von WinForms entkoppeln - net10.0 statt net10.0-windows

Core, alle drei Module und das Testprojekt tragen keinen UI-Code mehr und
bauen fuer linux-x64. Nur noch IBKRTrader.App ist Windows-gebunden.

UI-Contract toolkit-neutral (Vorbild: PolytraderSharp):
- ModuleView.CreateForm (Func<Form>) -> CreateView (Func<object>)
- ModuleView.Icon (System.Drawing.Image) -> IconKey (string).
  System.Drawing.Common ist seit .NET 7 Windows-only und wirft auf Linux.
- WindowMenu.cs war reine WinForms-Umsetzung -> in die Shell verschoben.

LoggingService haelt keine RichTextBox mehr, sondern meldet Eintraege ueber
event EntryWritten. Einfaerbung und UI-Thread-Wechsel liegen jetzt im
LogPanelController der Shell. Nebenbei: ToUpper() -> ToUpperInvariant()
(tr-TR haette aus "info" ein "İNFO" gemacht) und \r\n -> Environment.NewLine.

Die drei Modul-Fenster liegen jetzt unter UI/Views/Modules/; RegisterUi der
Module ist bewusst leer, die Shell registriert sie zentral ueber
UI/ModuleViews.cs (nur fuer tatsaechlich geladene Module). ViewIcons loest
IconKey gegen die PNG-Ressourcen auf - dieselben Schluessel bekommt spaeter
die Avalonia-Shell.

UiConstructionTests entfernt: die Konstruktionspruefung deckt --smoke-ui ab,
das Testprojekt braucht dafuer keine UI-Referenz mehr. Der Test
RegisterUi_RegistersMainView haelt jetzt das Gegenteil fest - das Modul darf
nichts registrieren, sonst waere es wieder toolkit-gebunden.

Verifiziert: Build 0 Fehler/0 Warnungen, 163 Tests gruen, --smoke-ui
konstruiert alle 7 Fenster, und Core + 3 Module + Tests bauen fuer linux-x64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 22:38:01 +02:00
co-authored by Claude Opus 5
parent 18b1059fa9
commit 123f38ab6f
22 changed files with 302 additions and 259 deletions
+302
View File
@@ -0,0 +1,302 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Modules.Accounting.Logic;
using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services;
namespace IBKRTrader.UI.Views.Modules;
/// <summary>
/// Fenster des Accounting-Moduls: Übersicht/BWA, Ledger, Steuer (Platzhalter), Abrechnung/Export,
/// Abruf/Status. Alle DB-Zugriffe laufen NUR auf Nutzer-Interaktion (nicht im Konstruktor) so
/// konstruiert der Smoke-UI-Check das Fenster auch ohne DB fehlerfrei.
/// </summary>
public sealed class AccountingMainForm : Form
{
private readonly ILedgerRepository _ledger;
private readonly IIngestRunRepository _runs;
private readonly AccountingReportService _report;
private readonly AccountingIngestService _ingest;
private readonly LoggingService _logger;
private readonly DateTimePicker _from = new() { Format = DateTimePickerFormat.Short, Width = 110 };
private readonly DateTimePicker _to = new() { Format = DateTimePickerFormat.Short, Width = 110 };
private readonly ComboBox _account = new() { DropDownStyle = ComboBoxStyle.DropDown, Width = 140 };
private readonly ComboBox _currency = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 80 };
private readonly Label _kpis = new() { AutoSize = true, Location = new Point(12, 8) };
private readonly DataGridView _monthly = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
private readonly DataGridView _ledgerGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
private readonly DataGridView _runsGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
private readonly Label _status = new() { AutoSize = true, ForeColor = SystemColors.GrayText, Location = new Point(12, 8) };
public AccountingMainForm(
ILedgerRepository ledger, IIngestRunRepository runs, AccountingReportService report,
AccountingIngestService ingest, LoggingService logger)
{
_ledger = ledger;
_runs = runs;
_report = report;
_ingest = ingest;
_logger = logger;
Text = "Accounting";
Width = 1000;
Height = 680;
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(760, 480);
_from.Value = DateTime.Today.AddMonths(-1);
_to.Value = DateTime.Today;
_currency.Items.AddRange(new object[] { "USD", "EUR" });
_currency.SelectedIndex = 0;
BuildLayout();
}
private void BuildLayout()
{
var tabs = new TabControl { Dock = DockStyle.Fill };
// ── gemeinsame Filterleiste ──
var filter = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 40, Padding = new Padding(8, 6, 8, 0) };
filter.Controls.Add(new Label { Text = "Von", AutoSize = true, Margin = new Padding(0, 8, 4, 0) });
filter.Controls.Add(_from);
filter.Controls.Add(new Label { Text = "Bis", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
filter.Controls.Add(_to);
filter.Controls.Add(new Label { Text = "Konto", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
filter.Controls.Add(_account);
filter.Controls.Add(new Label { Text = "Währung", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
filter.Controls.Add(_currency);
var btnRefresh = new Button { Text = "Aktualisieren", Width = 120, Margin = new Padding(12, 3, 0, 0) };
btnRefresh.Click += (_, _) => RefreshAll();
filter.Controls.Add(btnRefresh);
// ── Tab: Übersicht/BWA ──
var tabOverview = new TabPage("Übersicht / BWA");
_monthly.Top = 90;
var overviewPanel = new Panel { Dock = DockStyle.Fill };
overviewPanel.Controls.Add(_monthly);
var kpiPanel = new Panel { Dock = DockStyle.Top, Height = 84 };
kpiPanel.Controls.Add(_kpis);
overviewPanel.Controls.Add(kpiPanel);
tabOverview.Controls.Add(overviewPanel);
// ── Tab: Ledger ──
var tabLedger = new TabPage("Ledger");
tabLedger.Controls.Add(_ledgerGrid);
// ── Tab: Steuer (Platzhalter) ──
var tabTax = new TabPage("Steuer");
tabTax.Controls.Add(new Label
{
Dock = DockStyle.Fill, Padding = new Padding(16),
Text = "Steuerliche Einordnung ist noch offen (Jurisdiktion nicht festgelegt).\n\n" +
"Der neutrale Ledger und die Periodenabrechnung sind davon unabhängig gültig.\n" +
"Eine konkrete Steuerschicht (z. B. DE-Kapitalertragsteuer oder US Form 8949 / Schedule D)\n" +
"wird hier später als klar dokumentierte, prüfbare Rechenschicht ergänzt.\n\n" +
"Hinweis: Dies ist keine Steuerberatung."
});
// ── Tab: Abrechnung / Export ──
var tabExport = new TabPage("Abrechnung / Export");
var exportPanel = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(16), FlowDirection = FlowDirection.TopDown };
exportPanel.Controls.Add(new Label { AutoSize = true, Text = "Exportiert die aktuelle Auswahl (Zeitraum / Konto / Währung):" });
var btnCsvLedger = new Button { Text = "Ledger als CSV…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
btnCsvLedger.Click += (_, _) => ExportCsvLedger();
var btnCsvStmt = new Button { Text = "Abrechnung als CSV…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
btnCsvStmt.Click += (_, _) => ExportCsvStatement();
var btnPdf = new Button { Text = "Abrechnung als PDF…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
btnPdf.Click += (_, _) => ExportPdf();
exportPanel.Controls.Add(btnCsvLedger);
exportPanel.Controls.Add(btnCsvStmt);
exportPanel.Controls.Add(btnPdf);
tabExport.Controls.Add(exportPanel);
// ── Tab: Abruf / Status ──
var tabIngest = new TabPage("Abruf / Status");
var ingestButtons = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 40, Padding = new Padding(8, 6, 8, 0) };
var btnIncr = new Button { Text = "Inkrementell abrufen", Width = 160 };
btnIncr.Click += async (_, _) => await RunIngest(backfill: false);
var btnBackfill = new Button { Text = "Backfill (voll)", Width = 140, Margin = new Padding(8, 0, 0, 0) };
btnBackfill.Click += async (_, _) => await RunIngest(backfill: true);
ingestButtons.Controls.Add(btnIncr);
ingestButtons.Controls.Add(btnBackfill);
var statusPanel = new Panel { Dock = DockStyle.Top, Height = 40 };
statusPanel.Controls.Add(_status);
_status.Text = "Offline-Standard: keine Live-Quelle registriert → der Ingest bucht nichts (korrekt).";
tabIngest.Controls.Add(_runsGrid);
tabIngest.Controls.Add(statusPanel);
tabIngest.Controls.Add(ingestButtons);
tabs.TabPages.AddRange(new[] { tabOverview, tabLedger, tabTax, tabExport, tabIngest });
Controls.Add(tabs);
Controls.Add(filter);
}
// ── Daten laden (nur auf Interaktion) ──
private string? SelectedAccount()
{
var text = _account.Text?.Trim();
return string.IsNullOrWhiteSpace(text) || text == "(alle)" ? null : text;
}
private void RefreshAll()
{
try
{
LoadAccounts();
LoadOverview();
LoadLedger();
LoadRuns();
}
catch (Exception ex)
{
_logger.Error("Accounting", $"UI-Refresh fehlgeschlagen: {ex.Message}", ex);
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private void LoadAccounts()
{
var current = _account.Text;
_account.Items.Clear();
_account.Items.Add("(alle)");
foreach (var a in _ledger.DistinctAccounts()) _account.Items.Add(a);
_account.Text = string.IsNullOrEmpty(current) ? "(alle)" : current;
}
private void LoadOverview()
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var stmt = _report.BuildStatement(SelectedAccount(), from, to);
var view = _report.GetCurrencyView(_currency.Text, to);
decimal C(decimal v) => Math.Round(v * view.Factor, 2);
_kpis.Text =
$"Netto-Handelsergebnis: {C(stmt.NetTradingResult):N2} {view.Code} " +
$"Handelsvolumen: {C(stmt.TradeVolume):N2} Dividenden: {C(stmt.Dividends):N2} Fees: {C(stmt.Fees):N2}\n" +
$"Einzahlungen: {C(stmt.Deposits):N2} Auszahlungen: {C(stmt.Withdrawals):N2} " +
$"Endsaldo: {C(stmt.ClosingBalance):N2} Trades: {stmt.TradeCount} Buchungen: {stmt.EntryCount}\n" +
$"{view.Note}";
var monthly = _report.BuildMonthly(SelectedAccount(), from, to)
.Select(m => new
{
Monat = m.From.ToString("yyyy-MM"),
Anfang = C(m.OpeningBalance),
Einzahlungen = C(m.Deposits),
Auszahlungen = C(m.Withdrawals),
Volumen = C(m.TradeVolume),
Fees = C(m.Fees),
Ergebnis = C(m.NetTradingResult),
Endsaldo = C(m.ClosingBalance)
}).ToList();
_monthly.DataSource = monthly;
}
private void LoadLedger()
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var rows = _ledger.Query(SelectedAccount(), from, to, 2000)
.Select(e => new
{
Zeit = e.Timestamp, e.AccountId, Typ = e.EventType.ToString(), e.Side, e.Symbol,
e.Currency, e.Quantity, Preis = e.PriceNative, Brutto = e.GrossBase, Fee = e.FeeBase,
Netto = e.NetBase, e.TransactionId
}).ToList();
_ledgerGrid.DataSource = rows;
}
private void LoadRuns()
{
var rows = _runs.GetRecent(SelectedAccount(), 100)
.Select(r => new
{
r.AccountId, Start = r.StartedAt, Ende = r.FinishedAt, r.Backfill,
Neu = r.NewEntries, Duplikate = r.DuplicateEntries, r.Success,
Anker = r.BalanceAnchorBase, LedgerNetto = r.LedgerNetBase, Delta = r.BalanceDeltaBase, r.Message
}).ToList();
_runsGrid.DataSource = rows;
}
// ── Export ──
private void ExportCsvLedger()
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var entries = _ledger.Query(SelectedAccount(), from, to, 100000);
SaveText("ledger.csv", "CSV|*.csv", CsvExporter.Ledger(entries));
}
private void ExportCsvStatement()
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var stmt = _report.BuildStatement(SelectedAccount(), from, to);
SaveText("abrechnung.csv", "CSV|*.csv", CsvExporter.Statement(stmt));
}
private void ExportPdf()
{
try
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var account = SelectedAccount();
var stmt = _report.BuildStatement(account, from, to);
var monthly = _report.BuildMonthly(account, from, to);
var entries = _ledger.Query(account, from, to, 100000).OrderBy(e => e.Timestamp).ToList();
var view = _report.GetCurrencyView(_currency.Text, to);
byte[] pdf = PdfExporter.Render(stmt, monthly, entries, view.Code, view.Factor, view.Note);
using var dlg = new SaveFileDialog { FileName = "abrechnung.pdf", Filter = "PDF|*.pdf" };
if (dlg.ShowDialog(this) == DialogResult.OK)
{
File.WriteAllBytes(dlg.FileName, pdf);
_logger.Info("Accounting", $"PDF-Abrechnung geschrieben: {dlg.FileName}");
}
}
catch (Exception ex)
{
_logger.Error("Accounting", $"PDF-Export fehlgeschlagen: {ex.Message}", ex);
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private void SaveText(string suggested, string filter, string content)
{
try
{
using var dlg = new SaveFileDialog { FileName = suggested, Filter = filter };
if (dlg.ShowDialog(this) == DialogResult.OK)
{
File.WriteAllText(dlg.FileName, content);
_logger.Info("Accounting", $"Export geschrieben: {dlg.FileName}");
}
}
catch (Exception ex)
{
_logger.Error("Accounting", $"Export fehlgeschlagen: {ex.Message}", ex);
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
// ── Ingest ──
private async Task RunIngest(bool backfill)
{
try
{
_status.Text = backfill ? "Backfill läuft…" : "Inkrementeller Abruf läuft…";
await _ingest.IngestAllAsync(backfill, CancellationToken.None);
_status.Text = $"Abruf abgeschlossen ({DateTime.Now:HH:mm:ss}).";
LoadRuns();
}
catch (Exception ex)
{
_status.Text = $"Fehler: {ex.Message}";
_logger.Error("Accounting", $"Manueller Ingest fehlgeschlagen: {ex.Message}", ex);
}
}
}
+130
View File
@@ -0,0 +1,130 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.Modules.CongressTrading.Database;
namespace IBKRTrader.UI.Views.Modules;
/// <summary>
/// Eigenständiges Fenster des CongressTrading-Moduls: DB-Kennzahlen, manueller Scrape-Trigger
/// und die offenen Positionen des Moduls (aus dem Core-Portfolio).
/// </summary>
public sealed class CongressTradingForm : Form
{
private const string ScrapeWorkerName = "CT-ScrapeWorker";
private readonly CongressRepository _repo;
private readonly WorkerEngine _engine;
private readonly IPortfolioService _portfolio;
private readonly LoggingService _logger;
private readonly Label _lblTrades = new() { AutoSize = true, Location = new Point(20, 70) };
private readonly Label _lblMembers = new() { AutoSize = true, Location = new Point(20, 100) };
private readonly Button _btnRefresh = new() { Text = "Aktualisieren", Location = new Point(20, 140), Width = 140 };
private readonly Button _btnScrape = new() { Text = "Scrape jetzt", Location = new Point(170, 140), Width = 140 };
private readonly Label _lblStatus = new() { AutoSize = true, Location = new Point(20, 185), ForeColor = SystemColors.GrayText };
private readonly DataGridView _positions = new()
{
Location = new Point(20, 250),
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
ReadOnly = true,
AllowUserToAddRows = false,
RowHeadersVisible = false,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill
};
public CongressTradingForm(CongressRepository repo, WorkerEngine engine, IPortfolioService portfolio, LoggingService logger)
{
_repo = repo;
_engine = engine;
_portfolio = portfolio;
_logger = logger;
Text = "Congress Trading [CT]";
Width = 900;
Height = 600;
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(600, 400);
BuildLayout();
}
private void BuildLayout()
{
var title = new Label
{
Text = "Congress Trading",
Font = new Font(Font.FontFamily, 14f, FontStyle.Bold),
Location = new Point(18, 20),
AutoSize = true
};
var posLabel = new Label
{
Text = "Offene Positionen (Modul CT):",
Location = new Point(20, 225),
AutoSize = true
};
_positions.Size = new Size(ClientSize.Width - 40, ClientSize.Height - 270);
_btnRefresh.Click += async (_, _) => await RefreshStatsAsync();
_btnScrape.Click += async (_, _) => await TriggerScrapeAsync();
Controls.Add(title);
Controls.Add(_lblTrades);
Controls.Add(_lblMembers);
Controls.Add(_btnRefresh);
Controls.Add(_btnScrape);
Controls.Add(_lblStatus);
Controls.Add(posLabel);
Controls.Add(_positions);
}
protected override async void OnShown(EventArgs e)
{
base.OnShown(e);
await RefreshStatsAsync();
}
private async Task RefreshStatsAsync()
{
try
{
var trades = await _repo.GetTradeCountAsync();
var members = await _repo.GetMemberCountAsync();
_lblTrades.Text = $"Trades in DB: {trades:N0}";
_lblMembers.Text = $"Mitglieder in DB: {members:N0}";
var positions = await _portfolio.GetPositionsAsync(CongressTradingModule.LogTag);
_positions.DataSource = positions
.Select(p => new { p.Symbol, Stück = p.Quantity, Ø_Kurs = p.AvgPrice, Wert = p.Notional })
.ToList();
_lblStatus.Text = $"Aktualisiert: {DateTime.Now:HH:mm:ss}";
}
catch (Exception ex)
{
_lblTrades.Text = "Trades in DB: n/v";
_lblMembers.Text = "Mitglieder in DB: n/v";
_lblStatus.Text = $"DB nicht erreichbar: {ex.Message}";
_logger.Warn(CongressTradingModule.LogTag, $"Kennzahlen konnten nicht geladen werden: {ex.Message}");
}
}
private async Task TriggerScrapeAsync()
{
try
{
_lblStatus.Text = "Scrape angestoßen...";
await _engine.TriggerWorkerAsync(ScrapeWorkerName);
_logger.Info(CongressTradingModule.LogTag, "Scrape-Worker manuell ausgelöst (aus Modul-Fenster).");
}
catch (Exception ex)
{
_lblStatus.Text = $"Scrape fehlgeschlagen: {ex.Message}";
_logger.Error(CongressTradingModule.LogTag, "Manueller Scrape-Trigger fehlgeschlagen.", ex);
}
}
}
+188
View File
@@ -0,0 +1,188 @@
using System.Text;
using System.Text.Json;
using IBKRTrader.Core.Analytics;
using IBKRTrader.Core.Logging;
using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Persistence;
using IBKRTrader.Modules.Supervisor.Services;
namespace IBKRTrader.UI.Views.Modules;
/// <summary>
/// Fenster des Supervisor-Moduls: Analyse (Chat mit dem Agenten, Tool-Aufrufe sichtbar), Dossier-Browser,
/// Berichte, Settings. Read-only. DB-/Agent-Zugriffe laufen NUR auf Nutzer-Interaktion (Smoke-UI-sicher).
/// </summary>
public sealed class SupervisorMainForm : Form
{
private readonly SupervisorAgent _agent;
private readonly DossierService _dossiers;
private readonly ISupervisorReportRepository _reports;
private readonly LoggingService _logger;
private readonly ComboBox _profile = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 160 };
private readonly TextBox _question = new() { Dock = DockStyle.Fill, Multiline = true, Height = 60 };
private readonly RichTextBox _answer = new() { Dock = DockStyle.Fill, ReadOnly = true, Font = new Font("Consolas", 9f) };
private readonly Button _ask = new() { Text = "Fragen", Width = 100 };
private readonly DataGridView _signals = new() { Dock = DockStyle.Left, Width = 360, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
private readonly RichTextBox _dossier = new() { Dock = DockStyle.Fill, ReadOnly = true, Font = new Font("Consolas", 9f) };
private readonly DataGridView _reportsGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
public SupervisorMainForm(
SupervisorAgent agent, DossierService dossiers, ISupervisorReportRepository reports, LoggingService logger)
{
_agent = agent;
_dossiers = dossiers;
_reports = reports;
_logger = logger;
Text = "Supervisor";
Width = 1080;
Height = 720;
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(800, 520);
foreach (var p in SupervisorProfiles.All) _profile.Items.Add(p.Name);
_profile.SelectedIndex = 0;
BuildLayout();
}
private void BuildLayout()
{
var tabs = new TabControl { Dock = DockStyle.Fill };
// ── Tab: Analyse ──
var tabChat = new TabPage("Analyse");
var top = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 36, Padding = new Padding(8, 6, 8, 0) };
top.Controls.Add(new Label { Text = "Profil", AutoSize = true, Margin = new Padding(0, 8, 4, 0) });
top.Controls.Add(_profile);
_ask.Click += async (_, _) => await AskAsync();
var qPanel = new Panel { Dock = DockStyle.Top, Height = 70, Padding = new Padding(8, 2, 8, 4) };
qPanel.Controls.Add(_question);
var askPanel = new Panel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(8, 0, 8, 0) };
askPanel.Controls.Add(_ask);
var answerPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8) };
answerPanel.Controls.Add(_answer);
tabChat.Controls.Add(answerPanel);
tabChat.Controls.Add(askPanel);
tabChat.Controls.Add(qPanel);
tabChat.Controls.Add(top);
// ── Tab: Dossier-Browser ──
var tabDossier = new TabPage("Dossier-Browser");
_signals.SelectionChanged += (_, _) => ShowSelectedDossier();
var refreshSignals = new Button { Text = "Signale laden", Dock = DockStyle.Top, Height = 28 };
refreshSignals.Click += (_, _) => LoadSignals();
var left = new Panel { Dock = DockStyle.Left, Width = 360 };
left.Controls.Add(_signals);
left.Controls.Add(refreshSignals);
var dossierPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8) };
dossierPanel.Controls.Add(_dossier);
tabDossier.Controls.Add(dossierPanel);
tabDossier.Controls.Add(left);
// ── Tab: Berichte ──
var tabReports = new TabPage("Berichte");
var refreshReports = new Button { Text = "Berichte laden", Dock = DockStyle.Top, Height = 28 };
refreshReports.Click += (_, _) => LoadReports();
tabReports.Controls.Add(_reportsGrid);
tabReports.Controls.Add(refreshReports);
// ── Tab: Settings (Info) ──
var tabSettings = new TabPage("Settings");
tabSettings.Controls.Add(new Label
{
Dock = DockStyle.Fill, Padding = new Padding(16),
Text =
"Supervisor read-only Analyse/Forensik über alle Module.\n\n" +
"OpenRouter-Key: env IBKRTRADER_OPENROUTER_KEY oder Datei 'openrouter.key' (gitignored).\n" +
$" Status: {(string.IsNullOrEmpty(OpenRouterClient.DefaultApiKeyProvider()) ? "NICHT gesetzt Chat nicht verfügbar" : "gesetzt")}\n\n" +
"Tagesbericht (opt-in): env IBKRTRADER_SUPERVISOR_DAILY = Stunde 023.\n" +
"MCP-Light (opt-in): env IBKRTRADER_MCP_PORT = Port (bindet nur 127.0.0.1).\n\n" +
"Sicherheit: OpenRouter ist ein bewusst freigegebener externer Datenempfänger. Es werden nur\n" +
"Analyse-Daten der Tools gesendet, niemals Secrets. Kein Tool kann handeln oder schreiben."
});
tabs.TabPages.AddRange(new[] { tabChat, tabDossier, tabReports, tabSettings });
Controls.Add(tabs);
}
// ── Analyse ──
private async Task AskAsync()
{
var question = _question.Text.Trim();
if (string.IsNullOrEmpty(question)) return;
_ask.Enabled = false;
_answer.Clear();
var profile = SupervisorProfiles.ByName(_profile.Text);
var progress = new Progress<string>(s => AppendLine(s));
try
{
var result = await _agent.AskAsync(question, profile: profile, progress: progress);
AppendLine("");
AppendLine("─── Antwort ───");
AppendLine(result.Answer);
_reports.Insert(new SupervisorReport
{
Profile = profile.Name,
Model = SupervisorAgent.DefaultModel,
Question = question,
Answer = result.Answer,
ToolCallsJson = JsonSerializer.Serialize(result.ToolInvocations.Select(i => new { i.Tool, i.Arguments })),
ToolCallCount = result.ToolInvocations.Count,
PromptTokens = result.PromptTokens,
CompletionTokens = result.CompletionTokens
});
}
catch (Exception ex)
{
AppendLine("");
AppendLine($"FEHLER: {ex.Message}");
_logger.Warn("Supervisor", $"Analyse fehlgeschlagen: {ex.Message}");
}
finally
{
_ask.Enabled = true;
}
}
private void AppendLine(string text)
{
if (_answer.InvokeRequired) { _answer.BeginInvoke(() => AppendLine(text)); return; }
_answer.AppendText(text + "\n");
_answer.ScrollToCaret();
}
// ── Dossier ──
private void LoadSignals()
{
try { _signals.DataSource = _dossiers.RecentSignals(200); }
catch (Exception ex) { _logger.Warn("Supervisor", $"Signale laden fehlgeschlagen: {ex.Message}"); }
}
private void ShowSelectedDossier()
{
if (_signals.CurrentRow?.DataBoundItem is not SignalSummary s) return;
try { _dossier.Text = DossierBuilder.ToMarkdown(_dossiers.BuildForSignal(s.SignalId)); }
catch (Exception ex) { _dossier.Text = $"FEHLER: {ex.Message}"; }
}
// ── Berichte ──
private void LoadReports()
{
try
{
_reportsGrid.DataSource = _reports.GetRecent(100)
.Select(r => new { r.CreatedAt, r.Profile, r.Model, r.Question, r.ToolCallCount, r.PromptTokens, r.CompletionTokens })
.ToList();
}
catch (Exception ex) { _logger.Warn("Supervisor", $"Berichte laden fehlgeschlagen: {ex.Message}"); }
}
}