diff --git a/Core/Modules/IModule.cs b/Core/Modules/IModule.cs new file mode 100644 index 0000000..cbcdc9c --- /dev/null +++ b/Core/Modules/IModule.cs @@ -0,0 +1,44 @@ +using IBKRTrader.Core.Workers; +using Microsoft.Extensions.DependencyInjection; + +namespace IBKRTrader.Core.Modules; + +/// +/// Vertrag für ein eigenständiges Trading-Modul. +/// +/// Architektur-Regel: Der Core kennt konkrete Module NICHT. Module hängen sich +/// ausschließlich über diesen Vertrag und den DI-Container ein +/// (Abhängigkeit nur Modul → Core, niemals umgekehrt). +/// +/// Lebenszyklus: +/// 1. – vor dem Bauen des DI-Containers +/// 2. – beim App-Start (Migrationen etc.) +/// 3. – Worker werden der WorkerEngine übergeben +/// 4. – Launcher öffnet das Modul-Fenster +/// +public interface IModule +{ + /// Eindeutiges Modul-Kürzel, zugleich DB-Tabellen-Präfix (z. B. "CT"). + string Key { get; } + + /// Anzeigename im Launcher (z. B. "Congress Trading"). + string DisplayName { get; } + + /// Kurzbeschreibung der Strategie für den Launcher. + string Description { get; } + + /// Modul-Version. + string Version { get; } + + /// Registriert alle Modul-Services im DI-Container (vor dem Build). + void RegisterServices(IServiceCollection services); + + /// Führt Modul-Migrationen und einmalige Initialisierung aus (beim Start). + Task InitializeAsync(IServiceProvider provider); + + /// Liefert die Worker/Services des Moduls für die WorkerEngine. + IEnumerable GetWorkers(IServiceProvider provider); + + /// Erzeugt das eigenständige Fenster des Moduls (vom Launcher geöffnet). + Form CreateWindow(IServiceProvider provider); +} diff --git a/Core/Modules/ModuleRegistry.cs b/Core/Modules/ModuleRegistry.cs new file mode 100644 index 0000000..df1ea50 --- /dev/null +++ b/Core/Modules/ModuleRegistry.cs @@ -0,0 +1,22 @@ +namespace IBKRTrader.Core.Modules; + +/// +/// Zentrale Registry aller geladenen Module. +/// Wird in Program.cs mit den Modul-Instanzen befüllt und als Singleton +/// bereitgestellt, damit Launcher und Startup-Code über die Module iterieren +/// können, ohne konkrete Modultypen zu kennen. +/// +public class ModuleRegistry +{ + private readonly List _modules; + + public ModuleRegistry(IEnumerable modules) + => _modules = modules?.ToList() ?? throw new ArgumentNullException(nameof(modules)); + + /// Alle registrierten Module in Registrierungsreihenfolge. + public IReadOnlyList Modules => _modules; + + /// Findet ein Modul anhand seines Kürzels (case-insensitive) oder null. + public IModule? Find(string key) + => _modules.FirstOrDefault(m => string.Equals(m.Key, key, StringComparison.OrdinalIgnoreCase)); +} diff --git a/Core/Workers/WorkerBase.cs b/Core/Workers/WorkerBase.cs index 85d83bd..a8c8fc9 100644 --- a/Core/Workers/WorkerBase.cs +++ b/Core/Workers/WorkerBase.cs @@ -126,9 +126,9 @@ public abstract class WorkerBase : IWorker try { - logId = await Db.BeginWorkerLogAsync(Name, Module); + logId = await BeginRunLogAsync(); await ExecuteAsync(ct); - await Db.EndWorkerLogAsync(logId, true); + await EndRunLogAsync(logId, true); Info.LastRuntime = DateTime.Now; Info.Status = WorkerStatus.Idle; @@ -137,20 +137,30 @@ public abstract class WorkerBase : IWorker catch (OperationCanceledException) { if (logId > 0) - await Db.EndWorkerLogAsync(logId, false, "Abgebrochen"); + await EndRunLogAsync(logId, false, "Abgebrochen"); Info.Status = WorkerStatus.Stopped; Info.Info = "Abgebrochen"; } catch (Exception ex) { if (logId > 0) - await Db.EndWorkerLogAsync(logId, false, ex.Message); + await EndRunLogAsync(logId, false, ex.Message); Logger.Error(Module, $"Fehler in Worker {Name}: {ex.Message}", ex); Info.Status = WorkerStatus.Error; Info.Info = $"Fehler: {ex.Message}"; } } + // ─── DB-Log-Seam (überschreibbar für Unit-Tests) ────────────────────────── + + /// Legt den Worker-Log-Eintrag an. Kapselt den DB-Zugriff (testbar). + protected virtual Task BeginRunLogAsync() + => Db.BeginWorkerLogAsync(Name, Module); + + /// Schließt den Worker-Log-Eintrag ab. Kapselt den DB-Zugriff (testbar). + protected virtual Task EndRunLogAsync(long logId, bool success, string? message = null) + => Db.EndWorkerLogAsync(logId, success, message); + // ─── Hilfsmethoden ──────────────────────────────────────────────────────── private static string FormatInterval(TimeSpan ts) diff --git a/Form1.cs b/Form1.cs index 0466139..1786811 100644 --- a/Form1.cs +++ b/Form1.cs @@ -1,9 +1,10 @@ using IBKRTrader.Core.Database.Migrations; using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Modules; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Workers; -using IBKRTrader.Modules.CongressTrading; using IBKRTrader.UI; +using Microsoft.Extensions.DependencyInjection; namespace IBKRTrader; @@ -97,14 +98,18 @@ public partial class Form1 : Form _logger.Error("Core", "Core-Datenbankfehler beim Start.", ex); } - // Modul-Migrationen - try + // Modul-Migrationen: über die Registry iterieren (Core kennt kein Modul). + var registry = _services.GetRequiredService(); + foreach (var module in registry.Modules) { - await CongressTradingModule.InitializeAsync(_services); - } - catch (Exception ex) - { - _logger.Error("CT", "CongressTrading-Migrationen fehlgeschlagen.", ex); + try + { + await module.InitializeAsync(_services); + } + catch (Exception ex) + { + _logger.Error(module.Key, $"{module.DisplayName}: Initialisierung fehlgeschlagen.", ex); + } } // Worker starten diff --git a/IBKRTrader.Tests/IBKRTrader.Tests.csproj b/IBKRTrader.Tests/IBKRTrader.Tests.csproj index 496d249..9a409df 100644 --- a/IBKRTrader.Tests/IBKRTrader.Tests.csproj +++ b/IBKRTrader.Tests/IBKRTrader.Tests.csproj @@ -23,6 +23,13 @@ + + + + PreserveNewest + + + diff --git a/IBKRTrader.Tests/Modules/CapitolTradesScraperTests.cs b/IBKRTrader.Tests/Modules/CapitolTradesScraperTests.cs new file mode 100644 index 0000000..2997fb7 --- /dev/null +++ b/IBKRTrader.Tests/Modules/CapitolTradesScraperTests.cs @@ -0,0 +1,57 @@ +using FluentAssertions; +using IBKRTrader.Core.Logging; +using IBKRTrader.Modules.CongressTrading.Scraper; + +namespace IBKRTrader.Tests.Modules; + +[Trait("cat", "unit")] +public class CapitolTradesScraperTests +{ + private static string LoadFixture() + { + var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "ct_raw.html"); + File.Exists(path).Should().BeTrue($"Fixture fehlt: {path}"); + return File.ReadAllText(path); + } + + [Fact] + public void ParseTradesFromHtml_ExtractsTradesFromRscStream() + { + var scraper = new CapitolTradesScraper(new LoggingService()); + + var (trades, memberIds) = scraper.ParseTradesFromHtml(LoadFixture()); + + trades.Should().NotBeEmpty("die Fixture enthält einen RSC-Datenstrom mit Trades"); + memberIds.Should().NotBeEmpty(); + + var first = trades[0]; + first.TradeId.Should().NotBeNullOrWhiteSpace(); + first.TradeType.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void ParseTradesFromHtml_MapsMemberSnapshotConsistently() + { + var scraper = new CapitolTradesScraper(new LoggingService()); + + var (trades, memberIds) = scraper.ParseTradesFromHtml(LoadFixture()); + + // Jeder Member-Snapshot muss eine BioId haben, die auch im MemberBioIds-Set liegt. + foreach (var trade in trades.Where(t => t.MemberSnapshot is not null)) + { + trade.MemberSnapshot!.BioId.Should().NotBeNullOrWhiteSpace(); + memberIds.Should().Contain(trade.MemberBioId); + } + } + + [Fact] + public void ParseTradesFromHtml_EmptyHtml_ReturnsNoTrades() + { + var scraper = new CapitolTradesScraper(new LoggingService()); + + var (trades, memberIds) = scraper.ParseTradesFromHtml("nichts"); + + trades.Should().BeEmpty(); + memberIds.Should().BeEmpty(); + } +} diff --git a/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs b/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs new file mode 100644 index 0000000..903a17a --- /dev/null +++ b/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using IBKRTrader.Modules.CongressTrading; +using IBKRTrader.Modules.CongressTrading.Database; +using IBKRTrader.Modules.CongressTrading.Scraper; +using IBKRTrader.Modules.CongressTrading.Workers; +using Microsoft.Extensions.DependencyInjection; + +namespace IBKRTrader.Tests.Modules; + +[Trait("cat", "unit")] +public class CongressTradingModuleTests +{ + [Fact] + public void Metadata_IsAsExpected() + { + var module = new CongressTradingModule(); + + module.Key.Should().Be("CT"); + module.DisplayName.Should().NotBeNullOrWhiteSpace(); + module.Version.Should().Be("1.0.0"); + } + + [Fact] + public void RegisterServices_RegistersAllModuleServices() + { + var services = new ServiceCollection(); + + new CongressTradingModule().RegisterServices(services); + + var registered = services.Select(d => d.ServiceType).ToList(); + registered.Should().Contain(new[] + { + typeof(CongressMigrations), + typeof(CongressRepository), + typeof(CapitolTradesScraper), + typeof(CongressHistoryImportWorker), + typeof(CongressScrapeWorker), + }); + } + + [Fact] + public void CreateWindow_ReturnsForm() + { + using var form = new CongressTradingModule().CreateWindow(new ServiceCollection().BuildServiceProvider()); + + form.Should().NotBeNull(); + form.Text.Should().Contain("CT"); + } +} diff --git a/IBKRTrader.Tests/Modules/ModuleRegistryTests.cs b/IBKRTrader.Tests/Modules/ModuleRegistryTests.cs new file mode 100644 index 0000000..e53c085 --- /dev/null +++ b/IBKRTrader.Tests/Modules/ModuleRegistryTests.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using IBKRTrader.Core.Modules; +using IBKRTrader.Core.Workers; +using Microsoft.Extensions.DependencyInjection; + +namespace IBKRTrader.Tests.Modules; + +[Trait("cat", "unit")] +public class ModuleRegistryTests +{ + private sealed class FakeModule(string key) : IModule + { + public string Key => key; + public string DisplayName => $"Fake {key}"; + public string Description => "test"; + public string Version => "0.0.1"; + + public void RegisterServices(IServiceCollection services) { } + public Task InitializeAsync(IServiceProvider provider) => Task.CompletedTask; + public IEnumerable GetWorkers(IServiceProvider provider) => []; + public Form CreateWindow(IServiceProvider provider) => new(); + } + + [Fact] + public void Modules_PreservesRegistrationOrder() + { + var registry = new ModuleRegistry([new FakeModule("A"), new FakeModule("B")]); + + registry.Modules.Select(m => m.Key).Should().ContainInOrder("A", "B"); + } + + [Fact] + public void Find_IsCaseInsensitive() + { + var registry = new ModuleRegistry([new FakeModule("CT")]); + + registry.Find("ct").Should().NotBeNull(); + registry.Find("CT").Should().NotBeNull(); + registry.Find("XX").Should().BeNull(); + } + + [Fact] + public void Ctor_NullModules_Throws() + { + var act = () => new ModuleRegistry(null!); + + act.Should().Throw(); + } +} diff --git a/IBKRTrader.Tests/Workers/WorkerBaseTests.cs b/IBKRTrader.Tests/Workers/WorkerBaseTests.cs new file mode 100644 index 0000000..2234ffd --- /dev/null +++ b/IBKRTrader.Tests/Workers/WorkerBaseTests.cs @@ -0,0 +1,121 @@ +using System.Diagnostics; +using FluentAssertions; +using IBKRTrader.Core.Database; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Settings; +using IBKRTrader.Core.Workers; + +namespace IBKRTrader.Tests.Workers; + +[Trait("cat", "unit")] +public class WorkerBaseTests +{ + /// + /// Testbarer Worker: überschreibt den DB-Log-Seam (kein MySQL-Zugriff) und + /// zählt seine Läufe. Zustände werden per Polling geprüft, weil WorkerBase + /// Info.Status intern (und nach dem Log-Abschluss) setzt. + /// + private sealed class TestWorker : WorkerBase + { + private readonly TimeSpan? _interval; + private readonly Func _body; + + public int Runs; + + public override string Name => "TestWorker"; + public override string Module => "TEST"; + protected override TimeSpan? Interval => _interval; + + public TestWorker(TimeSpan? interval, Func body) + : base(new LoggingService(), + new DatabaseService(new SettingsService(), new LoggingService())) + { + _interval = interval; + _body = body; + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + Interlocked.Increment(ref Runs); + await _body(ct); + } + + // Seam überschreiben → kein DB-Zugriff. Positive Id, damit die End-Log-Aufrufe + // (in WorkerBase mit `logId > 0` geschützt) auch im Fehlerpfad laufen. + protected override Task BeginRunLogAsync() => Task.FromResult(1L); + protected override Task EndRunLogAsync(long logId, bool success, string? message = null) + => Task.CompletedTask; + } + + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private static async Task WaitUntilAsync(Func condition, string because) + { + var sw = Stopwatch.StartNew(); + while (!condition()) + { + if (sw.Elapsed > Timeout) + throw new TimeoutException($"Bedingung nicht innerhalb {Timeout.TotalSeconds}s erfüllt: {because}"); + await Task.Delay(15); + } + } + + [Fact] + public async Task RunsOnce_WhenIntervalIsNull_AndReportsIdle() + { + var worker = new TestWorker(interval: null, _ => Task.CompletedTask); + + await worker.StartAsync(CancellationToken.None); + // Hinweis: WorkerBase startet bereits im Zustand Idle – deshalb auf den + // abgeschlossenen Lauf warten (Runs == 1 UND wieder Idle). + await WaitUntilAsync( + () => worker.Runs == 1 && worker.Info.Status == WorkerStatus.Idle, + "ein Lauf ist abgeschlossen und Status zurück auf Idle"); + + worker.Runs.Should().Be(1); + + await worker.StopAsync(); + } + + [Fact] + public async Task Trigger_ForcesImmediateRun_BeforeIntervalElapses() + { + // Langes Intervall → ein zweiter Lauf kann nur durch Trigger entstehen. + var worker = new TestWorker(TimeSpan.FromMinutes(10), _ => Task.CompletedTask); + + await worker.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt"); + + await worker.TriggerAsync(); + await WaitUntilAsync(() => worker.Runs == 2, "Trigger löst zweiten Lauf aus"); + + await worker.StopAsync(); + } + + [Fact] + public async Task Exception_SetsStatusError_AndCapturesMessage() + { + var worker = new TestWorker(interval: null, + _ => throw new InvalidOperationException("boom")); + + await worker.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => worker.Info.Status == WorkerStatus.Error, "Status wird Error"); + + worker.Runs.Should().Be(1); + worker.Info.Info.Should().Contain("boom"); + + await worker.StopAsync(); + } + + [Fact] + public async Task Stop_SetsStatusStopped() + { + var worker = new TestWorker(TimeSpan.FromMinutes(10), _ => Task.CompletedTask); + + await worker.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt"); + await worker.StopAsync(); + + worker.Info.Status.Should().Be(WorkerStatus.Stopped); + } +} diff --git a/Modules/CongressTrading/CongressTradingModule.cs b/Modules/CongressTrading/CongressTradingModule.cs index 44e006a..4dac160 100644 --- a/Modules/CongressTrading/CongressTradingModule.cs +++ b/Modules/CongressTrading/CongressTradingModule.cs @@ -1,3 +1,5 @@ +using System.Drawing; +using IBKRTrader.Core.Modules; using IBKRTrader.Core.Workers; using IBKRTrader.Modules.CongressTrading.Database; using IBKRTrader.Modules.CongressTrading.Scraper; @@ -7,18 +9,24 @@ using Microsoft.Extensions.DependencyInjection; namespace IBKRTrader.Modules.CongressTrading; /// -/// Entry-Point des CongressTrading-Moduls. -/// Registriert alle modulspezifischen Services in den DI-Container. -/// -/// Architektur-Regel: Dieser Code wird von Program.cs aufgerufen. -/// Der Core kennt dieses Modul NICHT – die Abhängigkeit geht nur von Modul → Core. +/// CongressTrading-Modul: kopiert Aktien-Trades von US-Kongressmitgliedern +/// (Quelle: capitoltrades.com). +/// +/// Architektur-Regel: Dieses Modul hängt sich ausschließlich über den +/// -Vertrag ein. Der Core kennt dieses Modul NICHT – +/// die Abhängigkeit geht nur von Modul → Core. /// -public static class CongressTradingModule +public sealed class CongressTradingModule : IModule { public const string ModuleKey = "CT"; + public string Key => ModuleKey; + public string DisplayName => "Congress Trading"; + public string Description => "Kopiert Aktien-Trades von US-Kongressmitgliedern (capitoltrades.com)."; + public string Version => "1.0.0"; + /// Registriert alle Modul-Services im DI-Container. - public static void Register(IServiceCollection services) + public void RegisterServices(IServiceCollection services) { // Datenbank services.AddSingleton(); @@ -32,24 +40,42 @@ public static class CongressTradingModule services.AddSingleton(); } - /// - /// Führt die Modul-Migrationen aus und resettet ggf. den Fortschritt. - /// - public static async Task InitializeAsync(IServiceProvider provider) + /// Führt die Modul-Migrationen aus. + public async Task InitializeAsync(IServiceProvider provider) { var migrations = provider.GetRequiredService(); await migrations.RunAsync(); - // Einmaliger Reset: löscht alle alten (fehlerhaften) Fortschritts-Daten - // damit der Import sauber von Seite 1 neu startet. - var repo = provider.GetRequiredService(); + // Einmaliger Reset (deaktiviert): löscht alten Fortschritt für sauberen Neustart. + // var repo = provider.GetRequiredService(); // await repo.ResetHistoryImportAsync(); } /// Gibt die Modul-Worker für die WorkerEngine zurück. - public static IEnumerable GetWorkers(IServiceProvider provider) => + public IEnumerable GetWorkers(IServiceProvider provider) => [ provider.GetRequiredService(), provider.GetRequiredService() ]; + + /// + /// Erzeugt das Modul-Fenster. Platzhalter – die vollständige UI folgt in Phase 2. + /// + 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; + } } diff --git a/Modules/CongressTrading/Scraper/CapitolTradesScraper.cs b/Modules/CongressTrading/Scraper/CapitolTradesScraper.cs index 9a7dc73..6ac06b4 100644 --- a/Modules/CongressTrading/Scraper/CapitolTradesScraper.cs +++ b/Modules/CongressTrading/Scraper/CapitolTradesScraper.cs @@ -143,6 +143,16 @@ public class CapitolTradesScraper var url = $"{BaseUrl}/trades?pageSize=96&page={page}"; var html = await _http.GetStringAsync(url, ct); + return ParseTradesFromHtml(html, page); + } + + /// + /// Reine Parse-Logik: extrahiert Trades aus dem HTML (RSC-Stream) einer Trades-Seite. + /// Kein Netzwerkzugriff – dadurch offline gegen gespeicherte Fixtures testbar. + /// + public (List Trades, HashSet MemberBioIds) + ParseTradesFromHtml(string html, int page = 1) + { var trades = new List(); var memberIds = new HashSet(); diff --git a/Program.cs b/Program.cs index 8c1715b..adc3688 100644 --- a/Program.cs +++ b/Program.cs @@ -4,6 +4,7 @@ using IBKRTrader.Core.Database; using IBKRTrader.Core.Database.Migrations; using IBKRTrader.Core.IBKR; using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Modules; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Trading; using IBKRTrader.Core.Workers; @@ -53,7 +54,19 @@ internal static class Program services.AddSingleton(); // ── Module registrieren ────────────────────────────────────────────── - CongressTradingModule.Register(services); + // Der Core kennt konkrete Module nicht: Module werden hier instanziiert, + // hängen ihre Services über IModule.RegisterServices ein und werden über + // die ModuleRegistry zugänglich gemacht. + IModule[] modules = + [ + new CongressTradingModule(), + ]; + + foreach (var module in modules) + module.RegisterServices(services); + + var moduleRegistry = new ModuleRegistry(modules); + services.AddSingleton(moduleRegistry); // ── IWorker-Sammlung: Core + Module ────────────────────────────────── services.AddSingleton>(sp => new IWorker[] @@ -67,7 +80,7 @@ internal static class Program sp.GetRequiredService(), } // Modul-Worker dynamisch anhängen - .Concat(CongressTradingModule.GetWorkers(sp)) + .Concat(moduleRegistry.Modules.SelectMany(m => m.GetWorkers(sp))) .ToArray()); services.AddSingleton(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index be8ff0b..2a86768 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -101,12 +101,13 @@ WinForms selbst wird **nicht** unit-getestet – Logik in Services/Manager halte - [x] Repo-lokale `NuGet.config` (Test-Pakete in Allowlist ergänzt) - [x] Erster Smoke-Test grün (`dotnet test` → 2/2 bestanden) -### Phase 1 – Modul-System formalisieren (Refactoring, kein Verhaltensänderung) -- [ ] `Core/Modules/IModule.cs` (`Key`, `DisplayName`, `Description`, `Version`, `RegisterServices`, `InitializeAsync`, `GetWorkers`, `CreateWindow`) -- [ ] `Core/Modules/ModuleRegistry.cs` -- [ ] `CongressTradingModule` → `IModule` -- [ ] `Program.cs` iteriert über Registry statt CongressTrading hart zu nennen -- [ ] Tests: `ModuleRegistry`, `WorkerBase`, `CapitolTradesScraper` (gegen `ct_raw.html`) +### Phase 1 – Modul-System formalisieren (Refactoring, kein Verhaltensänderung) ✅ +- [x] `Core/Modules/IModule.cs` (`Key`, `DisplayName`, `Description`, `Version`, `RegisterServices`, `InitializeAsync`, `GetWorkers`, `CreateWindow`) +- [x] `Core/Modules/ModuleRegistry.cs` +- [x] `CongressTradingModule` → `IModule` (instanzbasiert, + Platzhalter-`CreateWindow`) +- [x] `Program.cs` iteriert über `IModule[]` + `ModuleRegistry`; `Form1` initialisiert Module über die Registry +- [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)