diff --git a/LauncherForm.cs b/LauncherForm.cs index a587ac2..42e70d9 100644 --- a/LauncherForm.cs +++ b/LauncherForm.cs @@ -115,7 +115,7 @@ public sealed class LauncherForm : Form module.StopAsync(default).GetAwaiter().GetResult(); } catch { /* Shutdown darf nicht am Modul scheitern */ } - _workerEngine.StopAllAsync().GetAwaiter().GetResult(); + // Worker/Services stoppt der Host in Program.Main via AppHost.StopAsync() nach Application.Run. base.OnFormClosing(e); } @@ -133,14 +133,13 @@ public sealed class LauncherForm : Form // Das gesamte Schema (core_ + ct_) läuft über EF-Migrationen, extern via // `dotnet ef database update` angewendet – keine Laufzeit-Migration mehr. + // Worker/Services laufen bereits (Generic Host, AppHost.Start()). Hier nur noch Modul-Start. foreach (var module in _modules) { try { await module.StartAsync(default); } catch (Exception ex) { _logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); } } - await _workerEngine.StartAllAsync(); - _logger.Info("Core", "IBKRTrader bereit."); SetStatus("Bereit"); UpdateButtonStates(); diff --git a/Program.cs b/Program.cs index 2e57466..aad81cb 100644 --- a/Program.cs +++ b/Program.cs @@ -61,10 +61,13 @@ internal static class Program }) .Build(); - // Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine-Referenz. + // Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine-Referenz (vor dem Start). AppHost.Services.GetRequiredService() .SetEngine(AppHost.Services.GetRequiredService()); + // Host starten → alle Worker/Services (IHostedService) laufen an. + AppHost.Start(); + // Views registrieren (Core + Module), dann Launcher starten. var uiHost = AppHost.Services.GetRequiredService(); RegisterCoreViews(uiHost, AppHost.Services); @@ -115,13 +118,20 @@ internal static class Program services.AddSingleton(); services.AddSingleton(); - // Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable. + // Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable (nur UI/Registry). services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + // Lebenszyklus über den Generic Host (jeder Worker ist ein IHostedService). + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddSingleton(); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 008c37e..50128ec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -100,9 +100,12 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin **R3 abgeschlossen** ✅ – gesamte Persistenz auf EF Core (Migrationen: `InitialCore`, `AddIbkr`, `InitialCongressTrading`), extern via `dotnet ef database update` anzuwenden. - Hinweis: nur build-verifizierbar (Unit-Tests ohne DB); Schema-Anwendung extern via `dotnet ef database update` (env `IBKRTRADER_MYSQL`) -### R4 – Trading-Kern einфügen -- [ ] Risk/Execution/Portfolio/Broker-Seam nach Core/Trading (aus Phase 3 portiert) -- [ ] Hintergrund-Jobs als `IHostedService` +### R4 – Worker auf `IHostedService` ✅ +- [x] `WorkerBase` implementiert `IHostedService`; `IWorker` auf Metadaten+Trigger reduziert +- [x] Worker via `AddHostedService` registriert; Lebenszyklus über `AppHost.Start()`/`StopAsync()` +- [x] `WorkerEngine` auf leichte Registry reduziert (WorkerInfos für UI + `TriggerWorkerAsync`) +- [x] `LauncherForm` startet/stoppt keine Worker mehr; Build + 43/43 Tests + smoke-ui + App-Start grün +- Hinweis: Trading-Kern (Risk/Execution/Portfolio/Broker) wurde bereits in Phase 3 gebaut und in R3 auf EF gehoben ### R5 – CongressTrading als vollständige Strategie - [ ] Scraper/Jobs → `IHostedService`; Signal → `ExecutionService` diff --git a/src/IBKRTrader.Core/IBKRTrader.Core.csproj b/src/IBKRTrader.Core/IBKRTrader.Core.csproj index bbb0ed6..043cd6a 100644 --- a/src/IBKRTrader.Core/IBKRTrader.Core.csproj +++ b/src/IBKRTrader.Core/IBKRTrader.Core.csproj @@ -13,6 +13,7 @@ + diff --git a/src/IBKRTrader.Core/Workers/IWorker.cs b/src/IBKRTrader.Core/Workers/IWorker.cs index 680c24d..aa4a27f 100644 --- a/src/IBKRTrader.Core/Workers/IWorker.cs +++ b/src/IBKRTrader.Core/Workers/IWorker.cs @@ -1,12 +1,13 @@ namespace IBKRTrader.Core.Workers; /// -/// Vertrag für jeden Worker oder Service im System. -/// Core und Module müssen dieses Interface implementieren. +/// Metadaten-/Steuer-Vertrag für jeden Worker oder Service. Der Lebenszyklus (Start/Stop) läuft +/// über (vom Generic Host getrieben); +/// dieses Interface liefert nur Anzeige-Infos und den manuellen Trigger. /// public interface IWorker { - /// Anzeigename in dgv_workerlist. + /// Anzeigename in der Worker-Liste. string Name { get; } /// Modul-Kürzel (z. B. "Core", "CT"). @@ -18,12 +19,6 @@ public interface IWorker /// Live-Daten für die DataGridView-Zeile. WorkerInfo Info { get; } - /// Startet den Worker/Service asynchron. - Task StartAsync(CancellationToken ct); - - /// Stoppt den Worker/Service sauber. - Task StopAsync(); - /// Löst einen sofortigen, manuellen Run aus (unabhängig vom Zeitplan). Task TriggerAsync(); } diff --git a/src/IBKRTrader.Core/Workers/WorkerBase.cs b/src/IBKRTrader.Core/Workers/WorkerBase.cs index 5e5639f..32217a1 100644 --- a/src/IBKRTrader.Core/Workers/WorkerBase.cs +++ b/src/IBKRTrader.Core/Workers/WorkerBase.cs @@ -2,17 +2,16 @@ using IBKRTrader.Core.Logging; using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Persistence.Entities; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; namespace IBKRTrader.Core.Workers; /// -/// Basisklasse für alle periodischen Worker. -/// – Verwaltet den internen CancellationToken-Lifecycle -/// – Führt ExecuteAsync() im konfigurierten Interval aus -/// – Aktualisiert WorkerInfo (LastRuntime, NextRuntime, Status, Info) -/// – Schreibt Runs in core_worker_log +/// Basisklasse für alle Worker/Services. Implementiert – Start/Stop +/// werden vom Generic Host getrieben. Führt ExecuteAsync() im konfigurierten Interval aus, +/// aktualisiert WorkerInfo und schreibt Runs in core_worker_log. /// -public abstract class WorkerBase : IWorker +public abstract class WorkerBase : IWorker, IHostedService { // ─── Abstrakte Member ───────────────────────────────────────────────────── @@ -56,25 +55,37 @@ public abstract class WorkerBase : IWorker // ─── Lifecycle ──────────────────────────────────────────────────────────── - public Task StartAsync(CancellationToken externalCt) + public Task StartAsync(CancellationToken cancellationToken) { + // Deaktivierte Worker starten nicht (Info.Active wird von abgeleiteten Klassen/Settings gesetzt). + if (!Info.Active) + { + Info.Status = WorkerStatus.Stopped; + Info.Info = "Deaktiviert"; + return Task.CompletedTask; + } + if (_runLoop is { IsCompleted: false }) return Task.CompletedTask; - // Lazy: RunEvery erst hier setzen, wenn alle Derived-Felder sicher initialisiert sind + // Lazy: RunEvery erst hier setzen, wenn alle Derived-Felder sicher initialisiert sind. if (string.IsNullOrEmpty(Info.RunEvery)) Info.RunEvery = Interval.HasValue ? FormatInterval(Interval.Value) : "Service"; - _cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt); + // Eigener CTS (nicht an den Startup-Token des Hosts gebunden – der ist nur für den Startvorgang). + _cts = new CancellationTokenSource(); _runLoop = Task.Run(() => RunLoopAsync(_cts.Token), _cts.Token); return Task.CompletedTask; } - public async Task StopAsync() + public async Task StopAsync(CancellationToken cancellationToken) { if (_cts == null) return; await _cts.CancelAsync(); if (_runLoop != null) - await _runLoop.ConfigureAwait(false); + { + try { await _runLoop.ConfigureAwait(false); } + catch (OperationCanceledException) { /* erwartet */ } + } Info.Status = WorkerStatus.Stopped; Info.Info = "Gestoppt"; diff --git a/src/IBKRTrader.Core/Workers/WorkerEngine.cs b/src/IBKRTrader.Core/Workers/WorkerEngine.cs index abd31e9..50f1d55 100644 --- a/src/IBKRTrader.Core/Workers/WorkerEngine.cs +++ b/src/IBKRTrader.Core/Workers/WorkerEngine.cs @@ -5,92 +5,31 @@ using IBKRTrader.Core.Logging; namespace IBKRTrader.Core.Workers; /// -/// Orchestriert alle registrierten IWorker. -/// – Startet/stoppt Worker -/// – Stellt BindingList für DataGridView bereit -/// – Thread-sicher via ConcurrentDictionary +/// Leichtgewichtige Registry über alle Worker/Services. Start/Stop übernimmt der Generic Host +/// (jeder Worker ist ein ). Diese Klasse +/// stellt die live-bindbare Liste für die Workers-Ansicht bereit und erlaubt den manuellen Trigger. /// public class WorkerEngine { - private readonly IEnumerable _workers; - private readonly LoggingService _logger; - private readonly ConcurrentDictionary _registry = new(); - private CancellationTokenSource _cts = new(); + private readonly ConcurrentDictionary _registry = new(); - /// Live-bindbare Liste für dgv_workerlist. + /// Live-bindbare Liste für die Workers-Ansicht (DataGridView). public BindingList WorkerInfos { get; } = []; public WorkerEngine(IEnumerable workers, LoggingService logger) { - _workers = workers; - _logger = logger; - } - - // ─── Engine-Lifecycle ───────────────────────────────────────────────────── - - public async Task StartAllAsync() - { - _cts = new CancellationTokenSource(); - - foreach (var worker in _workers) + foreach (var worker in workers) { - RegisterWorker(worker); - - if (worker.Info.Active) - await worker.StartAsync(_cts.Token); + _registry[worker.Name] = worker; + WorkerInfos.Add(worker.Info); } - - _logger.Info("Core", $"WorkerEngine gestartet – {_registry.Count} Worker/Services registriert."); - } - - public async Task StopAllAsync() - { - await _cts.CancelAsync(); - - var tasks = _registry.Values.Select(w => w.StopAsync()); - await Task.WhenAll(tasks); - - _logger.Info("Core", "WorkerEngine gestoppt."); - } - - // ─── Einzelsteuerung ────────────────────────────────────────────────────── - - public async Task StartWorkerAsync(string name) - { - if (!_registry.TryGetValue(name, out var worker)) return; - worker.Info.Active = true; - await worker.StartAsync(_cts.Token); - _logger.Info("Core", $"Worker manuell gestartet: {name}"); - } - - public async Task StopWorkerAsync(string name) - { - if (!_registry.TryGetValue(name, out var worker)) return; - worker.Info.Active = false; - await worker.StopAsync(); - _logger.Info("Core", $"Worker manuell gestoppt: {name}"); + logger.Info("Core", $"WorkerEngine: {_registry.Count} Worker/Services registriert."); } + /// Löst einen sofortigen manuellen Run des benannten Workers aus. public async Task TriggerWorkerAsync(string name) { - if (!_registry.TryGetValue(name, out var worker)) return; - await worker.TriggerAsync(); - _logger.Info("Core", $"Worker manuell ausgelöst: {name}"); - } - - // ─── Intern ─────────────────────────────────────────────────────────────── - - private void RegisterWorker(IWorker worker) - { - _registry[worker.Name] = worker; - - // WorkerInfo in BindingList eintragen (UI-Thread, falls nötig) - if (WorkerInfos is { } list) - { - if (SynchronizationContext.Current != null) - list.Add(worker.Info); - else - list.Add(worker.Info); - } + if (_registry.TryGetValue(name, out var worker)) + await worker.TriggerAsync(); } } diff --git a/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs b/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs index fba5eaf..b0fc0d5 100644 --- a/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs +++ b/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs @@ -38,9 +38,12 @@ public sealed class CongressTradingModule : IModule services.AddSingleton(); services.AddSingleton(); - // Worker als IWorker registrieren → die WorkerEngine erhält sie über IEnumerable. + // Worker als IWorker registrieren (WorkerEngine/UI-Registry) … services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + // … und als Hosted Services (Lebenszyklus über den Generic Host). + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); } public void RegisterUi(IModuleUiHost host, IServiceProvider services) diff --git a/tests/IBKRTrader.Tests/Workers/WorkerBaseTests.cs b/tests/IBKRTrader.Tests/Workers/WorkerBaseTests.cs index 8b5e714..746fb41 100644 --- a/tests/IBKRTrader.Tests/Workers/WorkerBaseTests.cs +++ b/tests/IBKRTrader.Tests/Workers/WorkerBaseTests.cs @@ -82,7 +82,7 @@ public class WorkerBaseTests worker.Runs.Should().Be(1); - await worker.StopAsync(); + await worker.StopAsync(CancellationToken.None); } [Fact] @@ -97,7 +97,7 @@ public class WorkerBaseTests await worker.TriggerAsync(); await WaitUntilAsync(() => worker.Runs == 2, "Trigger löst zweiten Lauf aus"); - await worker.StopAsync(); + await worker.StopAsync(CancellationToken.None); } [Fact] @@ -112,7 +112,7 @@ public class WorkerBaseTests worker.Runs.Should().Be(1); worker.Info.Info.Should().Contain("boom"); - await worker.StopAsync(); + await worker.StopAsync(CancellationToken.None); } [Fact] @@ -122,7 +122,7 @@ public class WorkerBaseTests await worker.StartAsync(CancellationToken.None); await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt"); - await worker.StopAsync(); + await worker.StopAsync(CancellationToken.None); worker.Info.Status.Should().Be(WorkerStatus.Stopped); }