Phase 2: Launcher-UI + eigenstaendige Modul-Fenster

- Form1 -> LauncherForm (Dateien via git mv, Designer/resx angepasst)
- Modul-Tab: Karten aus ModuleRegistry mit "Fenster oeffnen"-Button je Modul
- UI/WindowManager: Fenster-Tracking (Key->Form), Re-Open fokussiert, CloseAll
- UI/ModuleFormBase: Basisklasse fuer eigenstaendige Modul-Fenster
- CongressTradingForm: DB-Kennzahlen + manueller Scrape-Trigger (Phase-4-Ausbau folgt)
- WindowManager in DI; Launcher schliesst Modul-Fenster beim Beenden
- Tests: WindowManager (6) -> 21/21 gruen; Launcher-Start verifiziert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-27 10:39:50 +02:00
parent 5815d3258f
commit d0bc833235
11 changed files with 483 additions and 59 deletions
@@ -1,8 +1,13 @@
using FluentAssertions;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.CongressTrading.Scraper;
using IBKRTrader.Modules.CongressTrading.Workers;
using IBKRTrader.UI;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.Tests.Modules;
@@ -39,11 +44,22 @@ public class CongressTradingModuleTests
}
[Fact]
public void CreateWindow_ReturnsForm()
public void CreateWindow_ReturnsModuleForm()
{
using var form = new CongressTradingModule().CreateWindow(new ServiceCollection().BuildServiceProvider());
// Minimaler DI-Container mit allen von CreateWindow benötigten Abhängigkeiten.
var services = new ServiceCollection();
services.AddSingleton<SettingsService>();
services.AddSingleton<LoggingService>();
services.AddSingleton<DatabaseService>();
services.AddSingleton<IEnumerable<IWorker>>(_ => Array.Empty<IWorker>());
services.AddSingleton<WorkerEngine>();
var module = new CongressTradingModule();
module.RegisterServices(services);
form.Should().NotBeNull();
form.Text.Should().Contain("CT");
using var provider = services.BuildServiceProvider();
using var form = module.CreateWindow(provider);
form.Should().BeAssignableTo<ModuleFormBase>();
((ModuleFormBase)form).ModuleKey.Should().Be("CT");
}
}
+100
View File
@@ -0,0 +1,100 @@
using FluentAssertions;
using IBKRTrader.UI;
namespace IBKRTrader.Tests.UI;
[Trait("cat", "unit")]
public class WindowManagerTests
{
/// <summary>
/// Testbare Variante: unterdrückt echte Show-/Focus-Aufrufe (kein Message-Loop),
/// zählt aber die Focus-Aufrufe.
/// </summary>
private sealed class TestWindowManager : WindowManager
{
public int FocusCount;
protected override void Present(Form form) { /* kein Show im Test */ }
protected override void Focus(Form form) => FocusCount++;
}
[Fact]
public void OpenOrFocus_NewKey_InvokesFactory_AndTracks()
{
var wm = new TestWindowManager();
var created = 0;
using var form = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
created.Should().Be(1);
wm.IsOpen("CT").Should().BeTrue();
wm.OpenCount.Should().Be(1);
}
[Fact]
public void OpenOrFocus_SameKey_FocusesExisting_DoesNotRecreate()
{
var wm = new TestWindowManager();
var created = 0;
using var first = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
var second = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
created.Should().Be(1);
wm.FocusCount.Should().Be(1);
second.Should().BeSameAs(first);
wm.OpenCount.Should().Be(1);
}
[Fact]
public void OpenOrFocus_DifferentKeys_TracksSeparately()
{
var wm = new TestWindowManager();
using var a = wm.OpenOrFocus("A", () => new Form());
using var b = wm.OpenOrFocus("B", () => new Form());
wm.OpenCount.Should().Be(2);
wm.IsOpen("A").Should().BeTrue();
wm.IsOpen("B").Should().BeTrue();
}
[Fact]
public void OpenOrFocus_AfterDisposed_RecreatesWindow()
{
var wm = new TestWindowManager();
var created = 0;
var first = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
first.Dispose();
wm.IsOpen("CT").Should().BeFalse();
using var second = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
created.Should().Be(2);
second.Should().NotBeSameAs(first);
}
[Fact]
public void CloseAll_RemovesAllWindows()
{
var wm = new TestWindowManager();
wm.OpenOrFocus("A", () => new Form());
wm.OpenOrFocus("B", () => new Form());
wm.CloseAll();
wm.OpenCount.Should().Be(0);
wm.IsOpen("A").Should().BeFalse();
}
[Fact]
public void OpenOrFocus_BlankKey_Throws()
{
var wm = new TestWindowManager();
var act = () => wm.OpenOrFocus(" ", () => new Form());
act.Should().Throw<ArgumentException>();
}
}
+28 -14
View File
@@ -1,6 +1,6 @@
namespace IBKRTrader
{
partial class Form1
partial class LauncherForm
{
/// <summary>
/// Required designer variable.
@@ -28,14 +28,15 @@
/// </summary>
private void InitializeComponent()
{
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1));
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(LauncherForm));
menuStrip1 = new MenuStrip();
toolStrip1 = new ToolStrip();
statusStrip1 = new StatusStrip();
tabControl1 = new TabControl();
tabPage_dash = new TabPage();
tabPage_sett = new TabPage();
tabPage_mod_congresstrade = new TabPage();
tabPage_modules = new TabPage();
flp_modules = new FlowLayoutPanel();
btn_activateTrading = new ToolStripButton();
tabPage_history = new TabPage();
tabPage_workers = new TabPage();
@@ -89,7 +90,7 @@
tabControl1.Controls.Add(tabPage_sett);
tabControl1.Controls.Add(tabPage_logs);
tabControl1.Controls.Add(tabPage_workers);
tabControl1.Controls.Add(tabPage_mod_congresstrade);
tabControl1.Controls.Add(tabPage_modules);
tabControl1.Location = new Point(12, 52);
tabControl1.Name = "tabControl1";
tabControl1.SelectedIndex = 0;
@@ -117,14 +118,26 @@
tabPage_sett.Text = "Settings";
tabPage_sett.UseVisualStyleBackColor = true;
//
// tabPage_mod_congresstrade
// tabPage_modules
//
tabPage_mod_congresstrade.Location = new Point(4, 34);
tabPage_mod_congresstrade.Name = "tabPage_mod_congresstrade";
tabPage_mod_congresstrade.Size = new Size(2421, 1104);
tabPage_mod_congresstrade.TabIndex = 2;
tabPage_mod_congresstrade.Text = "Module: CongressTrading";
tabPage_mod_congresstrade.UseVisualStyleBackColor = true;
tabPage_modules.Controls.Add(flp_modules);
tabPage_modules.Location = new Point(4, 34);
tabPage_modules.Name = "tabPage_modules";
tabPage_modules.Padding = new Padding(3);
tabPage_modules.Size = new Size(2421, 1104);
tabPage_modules.TabIndex = 2;
tabPage_modules.Text = "Module";
tabPage_modules.UseVisualStyleBackColor = true;
//
// flp_modules
//
flp_modules.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
flp_modules.AutoScroll = true;
flp_modules.Location = new Point(6, 6);
flp_modules.Name = "flp_modules";
flp_modules.Padding = new Padding(6);
flp_modules.Size = new Size(2409, 1092);
flp_modules.TabIndex = 0;
//
// btn_activateTrading
//
@@ -223,8 +236,8 @@
Controls.Add(toolStrip1);
Controls.Add(menuStrip1);
MainMenuStrip = menuStrip1;
Name = "Form1";
Text = "IBKRTrader";
Name = "LauncherForm";
Text = "IBKRTrader — Launcher";
toolStrip1.ResumeLayout(false);
toolStrip1.PerformLayout();
tabControl1.ResumeLayout(false);
@@ -247,7 +260,8 @@
private TabControl tabControl1;
private TabPage tabPage_dash;
private TabPage tabPage_sett;
private TabPage tabPage_mod_congresstrade;
private TabPage tabPage_modules;
private FlowLayoutPanel flp_modules;
private TabPage tabPage_history;
private TabPage tabPage_workers;
private DataGridView dgv_workerlist;
+99 -11
View File
@@ -4,27 +4,35 @@ using IBKRTrader.Core.Modules;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Workers;
using IBKRTrader.UI;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader;
public partial class Form1 : Form
/// <summary>
/// Launcher das Basis-Fenster der Anwendung.
/// Enthält die Core-Panels (Workers, Logs, Settings) und eine Modul-Liste,
/// aus der jedes Modul als eigenständiges Fenster geöffnet wird.
/// </summary>
public partial class LauncherForm : Form
{
private readonly LoggingService _logger;
private readonly WorkerEngine _workerEngine;
private readonly SettingsService _settings;
private readonly CoreMigrations _migrations;
private readonly IBKRMigrations _ibkrMigrations;
private readonly LoggingService _logger;
private readonly WorkerEngine _workerEngine;
private readonly SettingsService _settings;
private readonly CoreMigrations _migrations;
private readonly IBKRMigrations _ibkrMigrations;
private readonly ModuleRegistry _modules;
private readonly WindowManager _windows;
private readonly IServiceProvider _services;
private LogPanelController? _logPanel;
public Form1(
public LauncherForm(
LoggingService logger,
WorkerEngine workerEngine,
SettingsService settings,
CoreMigrations migrations,
IBKRMigrations ibkrMigrations,
ModuleRegistry modules,
WindowManager windows,
IServiceProvider services)
{
InitializeComponent();
@@ -34,6 +42,8 @@ public partial class Form1 : Form
_settings = settings;
_migrations = migrations;
_ibkrMigrations = ibkrMigrations;
_modules = modules;
_windows = windows;
_services = services;
}
@@ -45,13 +55,15 @@ public partial class Form1 : Form
InitializeLogPanel();
InitializeWorkerList();
InitializeSettingsGrid();
InitializeModulePanel();
_ = StartupAsync();
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
base.OnFormClosing(e);
// Worker sauber beenden (feuert & vergisst Form muss kurz warten)
// Offene Modul-Fenster schließen, dann Worker sauber beenden.
_windows.CloseAll();
_workerEngine.StopAllAsync().GetAwaiter().GetResult();
}
@@ -79,6 +91,83 @@ public partial class Form1 : Form
pg_settings.SelectedObject = _settings.Settings;
}
/// <summary>Baut je Modul eine Karte mit „Fenster öffnen"-Button.</summary>
private void InitializeModulePanel()
{
flp_modules.FlowDirection = FlowDirection.LeftToRight;
flp_modules.WrapContents = true;
flp_modules.Controls.Clear();
foreach (var module in _modules.Modules)
flp_modules.Controls.Add(BuildModuleCard(module));
}
private Control BuildModuleCard(IModule module)
{
var card = new Panel
{
Width = 320,
Height = 150,
Margin = new Padding(8),
BorderStyle = BorderStyle.FixedSingle
};
var title = new Label
{
Text = $"{module.DisplayName} [{module.Key}]",
Font = new Font(Font.FontFamily, 11f, FontStyle.Bold),
Location = new Point(10, 10),
AutoSize = true
};
var desc = new Label
{
Text = module.Description,
Location = new Point(10, 42),
Size = new Size(300, 60),
AutoEllipsis = true
};
var open = new Button
{
Text = "Fenster öffnen",
Location = new Point(10, 108),
Width = 140,
Tag = module
};
open.Click += (_, _) => OpenModuleWindow(module);
var version = new Label
{
Text = $"v{module.Version}",
Location = new Point(240, 113),
AutoSize = true,
ForeColor = SystemColors.GrayText
};
card.Controls.Add(title);
card.Controls.Add(desc);
card.Controls.Add(open);
card.Controls.Add(version);
return card;
}
/// <summary>Öffnet (oder fokussiert) das eigenständige Fenster eines Moduls.</summary>
private void OpenModuleWindow(IModule module)
{
try
{
_windows.OpenOrFocus(module.Key, () => module.CreateWindow(_services));
}
catch (Exception ex)
{
_logger.Error(module.Key, $"{module.DisplayName}: Fenster konnte nicht geöffnet werden.", ex);
MessageBox.Show(this,
$"Fenster für '{module.DisplayName}' konnte nicht geöffnet werden:\n{ex.Message}",
"Modul-Fenster", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
// ─── Async Startup ────────────────────────────────────────────────────────
private async Task StartupAsync()
@@ -99,8 +188,7 @@ public partial class Form1 : Form
}
// Modul-Migrationen: über die Registry iterieren (Core kennt kein Modul).
var registry = _services.GetRequiredService<ModuleRegistry>();
foreach (var module in registry.Modules)
foreach (var module in _modules.Modules)
{
try
{
View File
@@ -1,8 +1,9 @@
using System.Drawing;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modules;
using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.CongressTrading.Scraper;
using IBKRTrader.Modules.CongressTrading.UI;
using IBKRTrader.Modules.CongressTrading.Workers;
using Microsoft.Extensions.DependencyInjection;
@@ -58,24 +59,10 @@ public sealed class CongressTradingModule : IModule
provider.GetRequiredService<CongressScrapeWorker>()
];
/// <summary>
/// Erzeugt das Modul-Fenster. Platzhalter die vollständige UI folgt in Phase 2.
/// </summary>
/// <summary>Erzeugt das eigenständige Modul-Fenster.</summary>
public Form CreateWindow(IServiceProvider provider)
{
var form = new Form
{
Text = $"{DisplayName} ({Key})",
Width = 800,
Height = 500,
StartPosition = FormStartPosition.CenterParent
};
form.Controls.Add(new Label
{
Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleCenter,
Text = $"{DisplayName}\n\nModul-UI folgt in Phase 2."
});
return form;
}
=> new CongressTradingForm(
provider.GetRequiredService<CongressRepository>(),
provider.GetRequiredService<WorkerEngine>(),
provider.GetRequiredService<LoggingService>());
}
@@ -0,0 +1,107 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.UI;
namespace IBKRTrader.Modules.CongressTrading.UI;
/// <summary>
/// Eigenständiges Fenster des CongressTrading-Moduls.
/// Phase 2: Grundgerüst mit DB-Kennzahlen und manuellem Scrape-Trigger.
/// Die vollständige Trade-/Positions-Ansicht folgt in Phase 4.
/// </summary>
public sealed class CongressTradingForm : ModuleFormBase
{
private const string ScrapeWorkerName = "CT-ScrapeWorker";
private readonly CongressRepository _repo;
private readonly WorkerEngine _engine;
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 };
public CongressTradingForm(CongressRepository repo, WorkerEngine engine, LoggingService logger)
: base(CongressTradingModule.ModuleKey)
{
_repo = repo;
_engine = engine;
_logger = logger;
Text = "Congress Trading [CT]";
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 hint = new Label
{
Text = "Vollständige Trade- und Positions-Ansicht folgt in Phase 4.",
Location = new Point(20, 230),
AutoSize = true,
ForeColor = SystemColors.GrayText
};
_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(hint);
}
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}";
_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.ModuleKey, $"Kennzahlen konnten nicht geladen werden: {ex.Message}");
}
}
private async Task TriggerScrapeAsync()
{
try
{
_lblStatus.Text = "Scrape angestoßen...";
await _engine.TriggerWorkerAsync(ScrapeWorkerName);
_logger.Info(CongressTradingModule.ModuleKey, "Scrape-Worker manuell ausgelöst (aus Modul-Fenster).");
}
catch (Exception ex)
{
_lblStatus.Text = $"Scrape fehlgeschlagen: {ex.Message}";
_logger.Error(CongressTradingModule.ModuleKey, "Manueller Scrape-Trigger fehlgeschlagen.", ex);
}
}
}
+6 -2
View File
@@ -10,6 +10,7 @@ using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using IBKRTrader.Core.Workers.BuiltIn;
using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.UI;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader;
@@ -84,7 +85,10 @@ internal static class Program
.ToArray());
services.AddSingleton<WorkerEngine>();
services.AddSingleton<Form1>();
// UI: Launcher + Fenster-Verwaltung
services.AddSingleton<WindowManager>();
services.AddSingleton<LauncherForm>();
var provider = services.BuildServiceProvider();
@@ -93,6 +97,6 @@ internal static class Program
var engine = provider.GetRequiredService<WorkerEngine>();
webApi.SetEngine(engine);
Application.Run(provider.GetRequiredService<Form1>());
Application.Run(provider.GetRequiredService<LauncherForm>());
}
}
+25
View File
@@ -0,0 +1,25 @@
namespace IBKRTrader.UI;
/// <summary>
/// Basisklasse für eigenständige Modul-Fenster.
/// Stellt gemeinsame Grundeinstellungen (Größe, Startposition) bereit und
/// hält den Modul-Key, damit der Launcher/WindowManager das Fenster zuordnen kann.
/// </summary>
public class ModuleFormBase : Form
{
/// <summary>Kürzel des zugehörigen Moduls (z. B. "CT").</summary>
public string ModuleKey { get; }
// Parameterloser Ctor nur für den WinForms-Designer.
protected ModuleFormBase() : this("") { }
protected ModuleFormBase(string moduleKey)
{
ModuleKey = moduleKey;
Width = 900;
Height = 600;
StartPosition = FormStartPosition.CenterScreen;
ShowInTaskbar = true;
MinimumSize = new Size(600, 400);
}
}
+82
View File
@@ -0,0 +1,82 @@
namespace IBKRTrader.UI;
/// <summary>
/// Verwaltet die eigenständigen Modul-Fenster des Launchers.
/// Pro Modul-Key wird höchstens ein Fenster gehalten: erneutes Öffnen
/// fokussiert das bestehende Fenster statt ein zweites zu erzeugen.
///
/// Die tatsächlichen UI-Aktionen (Show/Focus) sind in überschreibbare
/// Methoden gekapselt, damit die Tracking-Logik ohne Message-Loop testbar ist.
/// </summary>
public class WindowManager
{
private readonly Dictionary<string, Form> _open = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Öffnet das Fenster zum Key oder fokussiert ein bereits offenes.</summary>
public Form OpenOrFocus(string key, Func<Form> factory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(factory);
if (_open.TryGetValue(key, out var existing))
{
if (!existing.IsDisposed)
{
Focus(existing);
return existing;
}
_open.Remove(key);
}
var form = factory();
_open[key] = form;
form.FormClosed += OnFormClosed;
Present(form);
return form;
}
/// <summary>True, wenn zum Key aktuell ein nicht-disposetes Fenster offen ist.</summary>
public bool IsOpen(string key)
=> _open.TryGetValue(key, out var f) && !f.IsDisposed;
/// <summary>Anzahl aktuell offener Fenster.</summary>
public int OpenCount => _open.Values.Count(f => !f.IsDisposed);
/// <summary>Schließt alle offenen Fenster (z. B. beim Beenden des Launchers).</summary>
public void CloseAll()
{
foreach (var f in _open.Values.ToList())
{
if (!f.IsDisposed)
{
f.FormClosed -= OnFormClosed;
f.Close();
}
}
_open.Clear();
}
// ─── Überschreibbare UI-Aktionen (für Tests) ──────────────────────────────
/// <summary>Zeigt ein neu erzeugtes Fenster an.</summary>
protected virtual void Present(Form form) => form.Show();
/// <summary>Bringt ein bestehendes Fenster in den Vordergrund.</summary>
protected virtual void Focus(Form form)
{
if (form.WindowState == FormWindowState.Minimized)
form.WindowState = FormWindowState.Normal;
form.Activate();
form.BringToFront();
}
private void OnFormClosed(object? sender, FormClosedEventArgs e)
{
if (sender is not Form f) return;
f.FormClosed -= OnFormClosed;
var entry = _open.FirstOrDefault(p => ReferenceEquals(p.Value, f));
if (entry.Key is not null)
_open.Remove(entry.Key);
}
}
+7 -6
View File
@@ -109,12 +109,13 @@ WinForms selbst wird **nicht** unit-getestet Logik in Services/Manager halte
- [x] Testbarkeits-Seams: `WorkerBase.BeginRunLogAsync/EndRunLogAsync`, `CapitolTradesScraper.ParseTradesFromHtml`
- [x] Tests: `ModuleRegistry`, `CongressTradingModule`, `WorkerBase`, `CapitolTradesScraper` (gegen `ct_raw.html`) → **15/15 grün**
### Phase 2 Launcher-UI + Modul-Fenster
- [ ] `Form1``LauncherForm`; Kern-Panels behalten (Workers, Logs, Settings, Core-Status)
- [ ] Modul-Panel: Liste aus `ModuleRegistry`, je Modul „Fenster öffnen"
- [ ] `UI/ModuleFormBase.cs` + `UI/WindowManager.cs` (Fenster-Tracking)
- [ ] `Modules/CongressTrading/UI/CongressTradingForm.cs` (erstes Modul-Fenster)
- [ ] Tests: `WindowManager`
### Phase 2 Launcher-UI + Modul-Fenster
- [x] `Form1``LauncherForm` (Dateien via `git mv`, Designer/resx angepasst); Kern-Panels behalten
- [x] Modul-Tab: Karten aus `ModuleRegistry`, je Modul „Fenster öffnen"
- [x] `UI/ModuleFormBase.cs` + `UI/WindowManager.cs` (Fenster-Tracking, Re-Open fokussiert, `CloseAll`)
- [x] `Modules/CongressTrading/UI/CongressTradingForm.cs` (DB-Kennzahlen + „Scrape jetzt")
- [x] `WindowManager` in DI; Launcher schließt Modul-Fenster beim Beenden
- [x] Tests: `WindowManager` (6) → **21/21 grün**; Launcher-Start verifiziert
### Phase 3 Trading-Kern (Core)
- [ ] `Core/Trading/IIbkrClient.cs` (+ Adapter auf `IBKRGatewayService`)