R1: Umbau auf Multi-Projekt-Struktur (Vorbild PolytraderSharp)

Kurskorrektur: Vorbild ist PolytraderSharp (C#), nicht die veraltete Python-Version.
Reiner Strukturumbau, Verhalten unveraendert.

- src/IBKRTrader.Core (classlib): Core-Code + UI-Contract (ModuleFormBase, WindowManager)
- src/IBKRTrader.Modules.CongressTrading (classlib, referenziert nur Core)
- Root: IBKRTrader.App (WinExe) referenziert Core + Modul
- tests/IBKRTrader.Tests: Referenzen auf Core + Modul
- Neue .slnx; ungenutztes HtmlAgilityPack entfernt
- docs/ARCHITECTURE.md: korrigierter Ziel-Stand + R-Phasenplan
- Build + 38/38 Tests gruen; App startet

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-27 20:26:25 +02:00
parent 2ad4b55db1
commit c20538c8d8
61 changed files with 171 additions and 129 deletions
+172
View File
@@ -0,0 +1,172 @@
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.Workers;
/// <summary>
/// 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
/// </summary>
public abstract class WorkerBase : IWorker
{
// ─── Abstrakte Member ─────────────────────────────────────────────────────
public abstract string Name { get; }
public abstract string Module { get; }
public virtual WorkerType Type => WorkerType.Worker;
/// <summary>Intervall zwischen zwei Runs. null = Service (keine Wiederholung).</summary>
protected abstract TimeSpan? Interval { get; }
/// <summary>Führt die eigentliche Arbeit des Workers aus.</summary>
protected abstract Task ExecuteAsync(CancellationToken ct);
// ─── Infrastruktur ────────────────────────────────────────────────────────
protected readonly LoggingService Logger;
protected readonly DatabaseService Db;
public WorkerInfo Info { get; } = new();
private CancellationTokenSource? _cts;
private Task? _runLoop;
private readonly SemaphoreSlim _triggerSemaphore = new(0, 1);
protected WorkerBase(LoggingService logger, DatabaseService db)
{
Logger = logger;
Db = db;
Info.WorkerName = Name;
Info.Module = Module;
Info.Type = Type.ToString();
// Info.RunEvery wird NICHT hier gesetzt Interval ist abstract/virtual
// und die abgeleitete Klasse hat ihre Felder zu diesem Zeitpunkt noch
// nicht initialisiert (Base-Ctor läuft vor dem Derived-Ctor).
// → RunEvery wird lazy in StartAsync gesetzt.
Info.Status = WorkerStatus.Idle;
Info.Info = "Bereit";
Info.Active = true;
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
public Task StartAsync(CancellationToken externalCt)
{
if (_runLoop is { IsCompleted: false }) return Task.CompletedTask;
// 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);
_runLoop = Task.Run(() => RunLoopAsync(_cts.Token), _cts.Token);
return Task.CompletedTask;
}
public async Task StopAsync()
{
if (_cts == null) return;
await _cts.CancelAsync();
if (_runLoop != null)
await _runLoop.ConfigureAwait(false);
Info.Status = WorkerStatus.Stopped;
Info.Info = "Gestoppt";
}
public async Task TriggerAsync()
{
// Gibt das Semaphore frei der RunLoop führt sofort einen Run aus
if (_triggerSemaphore.CurrentCount == 0)
_triggerSemaphore.Release();
await Task.CompletedTask;
}
// ─── Interner Run-Loop ────────────────────────────────────────────────────
private async Task RunLoopAsync(CancellationToken ct)
{
Logger.Info(Module, $"Worker gestartet: {Name}");
while (!ct.IsCancellationRequested)
{
await RunOnceAsync(ct);
if (Interval == null) break; // Service: nur einmal
var next = DateTime.Now.Add(Interval.Value);
Info.NextRuntime = next;
// Warte auf Interval ODER manuellen Trigger
var remaining = next - DateTime.Now;
if (remaining > TimeSpan.Zero)
{
try
{
await _triggerSemaphore
.WaitAsync(remaining, ct)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { break; }
}
}
Logger.Info(Module, $"Worker beendet: {Name}");
}
private async Task RunOnceAsync(CancellationToken ct)
{
Info.Status = WorkerStatus.Running;
Info.Info = "Läuft...";
long logId = 0;
try
{
logId = await BeginRunLogAsync();
await ExecuteAsync(ct);
await EndRunLogAsync(logId, true);
Info.LastRuntime = DateTime.Now;
Info.Status = WorkerStatus.Idle;
Info.Info = $"OK {Info.LastRuntime:HH:mm:ss}";
}
catch (OperationCanceledException)
{
if (logId > 0)
await EndRunLogAsync(logId, false, "Abgebrochen");
Info.Status = WorkerStatus.Stopped;
Info.Info = "Abgebrochen";
}
catch (Exception ex)
{
if (logId > 0)
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) ──────────────────────────
/// <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 ────────────────────────────────────────────────────────
private static string FormatInterval(TimeSpan ts)
{
if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d";
if (ts.TotalHours >= 1) return $"{(int)ts.TotalHours}h";
return $"{(int)ts.TotalMinutes}m";
}
}