@
Phase 1: Modul-System formalisiert (IModule + ModuleRegistry) - Core/Modules/IModule.cs: Vertrag (Key, DisplayName, Description, Version, RegisterServices, InitializeAsync, GetWorkers, CreateWindow) - Core/Modules/ModuleRegistry.cs - CongressTradingModule auf IModule umgestellt (instanzbasiert, Platzhalter-Fenster) - Program.cs iteriert ueber IModule[] + Registry; Form1 initialisiert Module ueber Registry - Testbarkeit: WorkerBase DB-Log-Seam (virtuell); CapitolTradesScraper.ParseTradesFromHtml extrahiert (offline gegen ct_raw.html testbar) - Tests: ModuleRegistry, CongressTradingModule, WorkerBase, CapitolTradesScraper -> 15/15 gruen Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
using IBKRTrader.Core.Workers;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Core.Modules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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. <see cref="RegisterServices"/> – vor dem Bauen des DI-Containers
|
||||||
|
/// 2. <see cref="InitializeAsync"/> – beim App-Start (Migrationen etc.)
|
||||||
|
/// 3. <see cref="GetWorkers"/> – Worker werden der WorkerEngine übergeben
|
||||||
|
/// 4. <see cref="CreateWindow"/> – Launcher öffnet das Modul-Fenster
|
||||||
|
/// </summary>
|
||||||
|
public interface IModule
|
||||||
|
{
|
||||||
|
/// <summary>Eindeutiges Modul-Kürzel, zugleich DB-Tabellen-Präfix (z. B. "CT").</summary>
|
||||||
|
string Key { get; }
|
||||||
|
|
||||||
|
/// <summary>Anzeigename im Launcher (z. B. "Congress Trading").</summary>
|
||||||
|
string DisplayName { get; }
|
||||||
|
|
||||||
|
/// <summary>Kurzbeschreibung der Strategie für den Launcher.</summary>
|
||||||
|
string Description { get; }
|
||||||
|
|
||||||
|
/// <summary>Modul-Version.</summary>
|
||||||
|
string Version { get; }
|
||||||
|
|
||||||
|
/// <summary>Registriert alle Modul-Services im DI-Container (vor dem Build).</summary>
|
||||||
|
void RegisterServices(IServiceCollection services);
|
||||||
|
|
||||||
|
/// <summary>Führt Modul-Migrationen und einmalige Initialisierung aus (beim Start).</summary>
|
||||||
|
Task InitializeAsync(IServiceProvider provider);
|
||||||
|
|
||||||
|
/// <summary>Liefert die Worker/Services des Moduls für die WorkerEngine.</summary>
|
||||||
|
IEnumerable<IWorker> GetWorkers(IServiceProvider provider);
|
||||||
|
|
||||||
|
/// <summary>Erzeugt das eigenständige Fenster des Moduls (vom Launcher geöffnet).</summary>
|
||||||
|
Form CreateWindow(IServiceProvider provider);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace IBKRTrader.Core.Modules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class ModuleRegistry
|
||||||
|
{
|
||||||
|
private readonly List<IModule> _modules;
|
||||||
|
|
||||||
|
public ModuleRegistry(IEnumerable<IModule> modules)
|
||||||
|
=> _modules = modules?.ToList() ?? throw new ArgumentNullException(nameof(modules));
|
||||||
|
|
||||||
|
/// <summary>Alle registrierten Module in Registrierungsreihenfolge.</summary>
|
||||||
|
public IReadOnlyList<IModule> Modules => _modules;
|
||||||
|
|
||||||
|
/// <summary>Findet ein Modul anhand seines Kürzels (case-insensitive) oder null.</summary>
|
||||||
|
public IModule? Find(string key)
|
||||||
|
=> _modules.FirstOrDefault(m => string.Equals(m.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
@@ -126,9 +126,9 @@ public abstract class WorkerBase : IWorker
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
logId = await Db.BeginWorkerLogAsync(Name, Module);
|
logId = await BeginRunLogAsync();
|
||||||
await ExecuteAsync(ct);
|
await ExecuteAsync(ct);
|
||||||
await Db.EndWorkerLogAsync(logId, true);
|
await EndRunLogAsync(logId, true);
|
||||||
|
|
||||||
Info.LastRuntime = DateTime.Now;
|
Info.LastRuntime = DateTime.Now;
|
||||||
Info.Status = WorkerStatus.Idle;
|
Info.Status = WorkerStatus.Idle;
|
||||||
@@ -137,20 +137,30 @@ public abstract class WorkerBase : IWorker
|
|||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
if (logId > 0)
|
if (logId > 0)
|
||||||
await Db.EndWorkerLogAsync(logId, false, "Abgebrochen");
|
await EndRunLogAsync(logId, false, "Abgebrochen");
|
||||||
Info.Status = WorkerStatus.Stopped;
|
Info.Status = WorkerStatus.Stopped;
|
||||||
Info.Info = "Abgebrochen";
|
Info.Info = "Abgebrochen";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
if (logId > 0)
|
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);
|
Logger.Error(Module, $"Fehler in Worker {Name}: {ex.Message}", ex);
|
||||||
Info.Status = WorkerStatus.Error;
|
Info.Status = WorkerStatus.Error;
|
||||||
Info.Info = $"Fehler: {ex.Message}";
|
Info.Info = $"Fehler: {ex.Message}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── DB-Log-Seam (überschreibbar für Unit-Tests) ──────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Legt den Worker-Log-Eintrag an. Kapselt den DB-Zugriff (testbar).</summary>
|
||||||
|
protected virtual Task<long> BeginRunLogAsync()
|
||||||
|
=> Db.BeginWorkerLogAsync(Name, Module);
|
||||||
|
|
||||||
|
/// <summary>Schließt den Worker-Log-Eintrag ab. Kapselt den DB-Zugriff (testbar).</summary>
|
||||||
|
protected virtual Task EndRunLogAsync(long logId, bool success, string? message = null)
|
||||||
|
=> Db.EndWorkerLogAsync(logId, success, message);
|
||||||
|
|
||||||
// ─── Hilfsmethoden ────────────────────────────────────────────────────────
|
// ─── Hilfsmethoden ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static string FormatInterval(TimeSpan ts)
|
private static string FormatInterval(TimeSpan ts)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using IBKRTrader.Core.Database.Migrations;
|
using IBKRTrader.Core.Database.Migrations;
|
||||||
using IBKRTrader.Core.Logging;
|
using IBKRTrader.Core.Logging;
|
||||||
|
using IBKRTrader.Core.Modules;
|
||||||
using IBKRTrader.Core.Settings;
|
using IBKRTrader.Core.Settings;
|
||||||
using IBKRTrader.Core.Workers;
|
using IBKRTrader.Core.Workers;
|
||||||
using IBKRTrader.Modules.CongressTrading;
|
|
||||||
using IBKRTrader.UI;
|
using IBKRTrader.UI;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace IBKRTrader;
|
namespace IBKRTrader;
|
||||||
|
|
||||||
@@ -97,14 +98,18 @@ public partial class Form1 : Form
|
|||||||
_logger.Error("Core", "Core-Datenbankfehler beim Start.", ex);
|
_logger.Error("Core", "Core-Datenbankfehler beim Start.", ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modul-Migrationen
|
// Modul-Migrationen: über die Registry iterieren (Core kennt kein Modul).
|
||||||
try
|
var registry = _services.GetRequiredService<ModuleRegistry>();
|
||||||
|
foreach (var module in registry.Modules)
|
||||||
{
|
{
|
||||||
await CongressTradingModule.InitializeAsync(_services);
|
try
|
||||||
}
|
{
|
||||||
catch (Exception ex)
|
await module.InitializeAsync(_services);
|
||||||
{
|
}
|
||||||
_logger.Error("CT", "CongressTrading-Migrationen fehlgeschlagen.", ex);
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Error(module.Key, $"{module.DisplayName}: Initialisierung fehlgeschlagen.", ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Worker starten
|
// Worker starten
|
||||||
|
|||||||
@@ -23,6 +23,13 @@
|
|||||||
<ProjectReference Include="..\IBKRTrader.csproj" />
|
<ProjectReference Include="..\IBKRTrader.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Test-Fixture: gespeicherte capitoltrades.com-Seite (kein Netzzugriff im Test) -->
|
||||||
|
<Content Include="..\ct_raw.html" Link="Fixtures\ct_raw.html">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -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("<html><body>nichts</body></html>");
|
||||||
|
|
||||||
|
trades.Should().BeEmpty();
|
||||||
|
memberIds.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IWorker> 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<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class TestWorker : WorkerBase
|
||||||
|
{
|
||||||
|
private readonly TimeSpan? _interval;
|
||||||
|
private readonly Func<CancellationToken, Task> _body;
|
||||||
|
|
||||||
|
public int Runs;
|
||||||
|
|
||||||
|
public override string Name => "TestWorker";
|
||||||
|
public override string Module => "TEST";
|
||||||
|
protected override TimeSpan? Interval => _interval;
|
||||||
|
|
||||||
|
public TestWorker(TimeSpan? interval, Func<CancellationToken, Task> 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<long> 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<bool> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using IBKRTrader.Core.Modules;
|
||||||
using IBKRTrader.Core.Workers;
|
using IBKRTrader.Core.Workers;
|
||||||
using IBKRTrader.Modules.CongressTrading.Database;
|
using IBKRTrader.Modules.CongressTrading.Database;
|
||||||
using IBKRTrader.Modules.CongressTrading.Scraper;
|
using IBKRTrader.Modules.CongressTrading.Scraper;
|
||||||
@@ -7,18 +9,24 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
namespace IBKRTrader.Modules.CongressTrading;
|
namespace IBKRTrader.Modules.CongressTrading;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entry-Point des CongressTrading-Moduls.
|
/// CongressTrading-Modul: kopiert Aktien-Trades von US-Kongressmitgliedern
|
||||||
/// Registriert alle modulspezifischen Services in den DI-Container.
|
/// (Quelle: capitoltrades.com).
|
||||||
///
|
///
|
||||||
/// Architektur-Regel: Dieser Code wird von Program.cs aufgerufen.
|
/// Architektur-Regel: Dieses Modul hängt sich ausschließlich über den
|
||||||
/// Der Core kennt dieses Modul NICHT – die Abhängigkeit geht nur von Modul → Core.
|
/// <see cref="IModule"/>-Vertrag ein. Der Core kennt dieses Modul NICHT –
|
||||||
|
/// die Abhängigkeit geht nur von Modul → Core.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class CongressTradingModule
|
public sealed class CongressTradingModule : IModule
|
||||||
{
|
{
|
||||||
public const string ModuleKey = "CT";
|
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";
|
||||||
|
|
||||||
/// <summary>Registriert alle Modul-Services im DI-Container.</summary>
|
/// <summary>Registriert alle Modul-Services im DI-Container.</summary>
|
||||||
public static void Register(IServiceCollection services)
|
public void RegisterServices(IServiceCollection services)
|
||||||
{
|
{
|
||||||
// Datenbank
|
// Datenbank
|
||||||
services.AddSingleton<CongressMigrations>();
|
services.AddSingleton<CongressMigrations>();
|
||||||
@@ -32,24 +40,42 @@ public static class CongressTradingModule
|
|||||||
services.AddSingleton<CongressScrapeWorker>();
|
services.AddSingleton<CongressScrapeWorker>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Führt die Modul-Migrationen aus.</summary>
|
||||||
/// Führt die Modul-Migrationen aus und resettet ggf. den Fortschritt.
|
public async Task InitializeAsync(IServiceProvider provider)
|
||||||
/// </summary>
|
|
||||||
public static async Task InitializeAsync(IServiceProvider provider)
|
|
||||||
{
|
{
|
||||||
var migrations = provider.GetRequiredService<CongressMigrations>();
|
var migrations = provider.GetRequiredService<CongressMigrations>();
|
||||||
await migrations.RunAsync();
|
await migrations.RunAsync();
|
||||||
|
|
||||||
// Einmaliger Reset: löscht alle alten (fehlerhaften) Fortschritts-Daten
|
// Einmaliger Reset (deaktiviert): löscht alten Fortschritt für sauberen Neustart.
|
||||||
// damit der Import sauber von Seite 1 neu startet.
|
// var repo = provider.GetRequiredService<CongressRepository>();
|
||||||
var repo = provider.GetRequiredService<CongressRepository>();
|
|
||||||
// await repo.ResetHistoryImportAsync();
|
// await repo.ResetHistoryImportAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gibt die Modul-Worker für die WorkerEngine zurück.</summary>
|
/// <summary>Gibt die Modul-Worker für die WorkerEngine zurück.</summary>
|
||||||
public static IEnumerable<IWorker> GetWorkers(IServiceProvider provider) =>
|
public IEnumerable<IWorker> GetWorkers(IServiceProvider provider) =>
|
||||||
[
|
[
|
||||||
provider.GetRequiredService<CongressHistoryImportWorker>(),
|
provider.GetRequiredService<CongressHistoryImportWorker>(),
|
||||||
provider.GetRequiredService<CongressScrapeWorker>()
|
provider.GetRequiredService<CongressScrapeWorker>()
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Erzeugt das Modul-Fenster. Platzhalter – die vollständige UI folgt in Phase 2.
|
||||||
|
/// </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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,16 @@ public class CapitolTradesScraper
|
|||||||
var url = $"{BaseUrl}/trades?pageSize=96&page={page}";
|
var url = $"{BaseUrl}/trades?pageSize=96&page={page}";
|
||||||
var html = await _http.GetStringAsync(url, ct);
|
var html = await _http.GetStringAsync(url, ct);
|
||||||
|
|
||||||
|
return ParseTradesFromHtml(html, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reine Parse-Logik: extrahiert Trades aus dem HTML (RSC-Stream) einer Trades-Seite.
|
||||||
|
/// Kein Netzwerkzugriff – dadurch offline gegen gespeicherte Fixtures testbar.
|
||||||
|
/// </summary>
|
||||||
|
public (List<CongressTrade> Trades, HashSet<string> MemberBioIds)
|
||||||
|
ParseTradesFromHtml(string html, int page = 1)
|
||||||
|
{
|
||||||
var trades = new List<CongressTrade>();
|
var trades = new List<CongressTrade>();
|
||||||
var memberIds = new HashSet<string>();
|
var memberIds = new HashSet<string>();
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -4,6 +4,7 @@ using IBKRTrader.Core.Database;
|
|||||||
using IBKRTrader.Core.Database.Migrations;
|
using IBKRTrader.Core.Database.Migrations;
|
||||||
using IBKRTrader.Core.IBKR;
|
using IBKRTrader.Core.IBKR;
|
||||||
using IBKRTrader.Core.Logging;
|
using IBKRTrader.Core.Logging;
|
||||||
|
using IBKRTrader.Core.Modules;
|
||||||
using IBKRTrader.Core.Settings;
|
using IBKRTrader.Core.Settings;
|
||||||
using IBKRTrader.Core.Trading;
|
using IBKRTrader.Core.Trading;
|
||||||
using IBKRTrader.Core.Workers;
|
using IBKRTrader.Core.Workers;
|
||||||
@@ -53,7 +54,19 @@ internal static class Program
|
|||||||
services.AddSingleton<IBKRPriceHistoryWorker>();
|
services.AddSingleton<IBKRPriceHistoryWorker>();
|
||||||
|
|
||||||
// ── Module registrieren ──────────────────────────────────────────────
|
// ── 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 ──────────────────────────────────
|
// ── IWorker-Sammlung: Core + Module ──────────────────────────────────
|
||||||
services.AddSingleton<IEnumerable<IWorker>>(sp => new IWorker[]
|
services.AddSingleton<IEnumerable<IWorker>>(sp => new IWorker[]
|
||||||
@@ -67,7 +80,7 @@ internal static class Program
|
|||||||
sp.GetRequiredService<IBKRPriceHistoryWorker>(),
|
sp.GetRequiredService<IBKRPriceHistoryWorker>(),
|
||||||
}
|
}
|
||||||
// Modul-Worker dynamisch anhängen
|
// Modul-Worker dynamisch anhängen
|
||||||
.Concat(CongressTradingModule.GetWorkers(sp))
|
.Concat(moduleRegistry.Modules.SelectMany(m => m.GetWorkers(sp)))
|
||||||
.ToArray());
|
.ToArray());
|
||||||
|
|
||||||
services.AddSingleton<WorkerEngine>();
|
services.AddSingleton<WorkerEngine>();
|
||||||
|
|||||||
@@ -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] Repo-lokale `NuGet.config` (Test-Pakete in Allowlist ergänzt)
|
||||||
- [x] Erster Smoke-Test grün (`dotnet test` → 2/2 bestanden)
|
- [x] Erster Smoke-Test grün (`dotnet test` → 2/2 bestanden)
|
||||||
|
|
||||||
### Phase 1 – Modul-System formalisieren (Refactoring, kein Verhaltensänderung)
|
### Phase 1 – Modul-System formalisieren (Refactoring, kein Verhaltensänderung) ✅
|
||||||
- [ ] `Core/Modules/IModule.cs` (`Key`, `DisplayName`, `Description`, `Version`, `RegisterServices`, `InitializeAsync`, `GetWorkers`, `CreateWindow`)
|
- [x] `Core/Modules/IModule.cs` (`Key`, `DisplayName`, `Description`, `Version`, `RegisterServices`, `InitializeAsync`, `GetWorkers`, `CreateWindow`)
|
||||||
- [ ] `Core/Modules/ModuleRegistry.cs`
|
- [x] `Core/Modules/ModuleRegistry.cs`
|
||||||
- [ ] `CongressTradingModule` → `IModule`
|
- [x] `CongressTradingModule` → `IModule` (instanzbasiert, + Platzhalter-`CreateWindow`)
|
||||||
- [ ] `Program.cs` iteriert über Registry statt CongressTrading hart zu nennen
|
- [x] `Program.cs` iteriert über `IModule[]` + `ModuleRegistry`; `Form1` initialisiert Module über die Registry
|
||||||
- [ ] Tests: `ModuleRegistry`, `WorkerBase`, `CapitolTradesScraper` (gegen `ct_raw.html`)
|
- [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
|
### Phase 2 – Launcher-UI + Modul-Fenster
|
||||||
- [ ] `Form1` → `LauncherForm`; Kern-Panels behalten (Workers, Logs, Settings, Core-Status)
|
- [ ] `Form1` → `LauncherForm`; Kern-Panels behalten (Workers, Logs, Settings, Core-Status)
|
||||||
|
|||||||
Reference in New Issue
Block a user