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)