Initial commit: IBKRTrader
.NET WinForms-Anwendung (Core, Modules/CongressTrading, UI). Enthaelt .gitignore und settings.example.json als Konfigurationsvorlage. Echte settings.json mit Zugangsdaten ist bewusst ausgeschlossen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
using IBKRTrader.Core.Database;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Settings;
|
||||
|
||||
namespace IBKRTrader.Core.Workers.BuiltIn;
|
||||
|
||||
/// <summary>
|
||||
/// BackupWorker (Core) – läuft alle 30 Minuten (konfigurierbar).
|
||||
/// 1. Ruft mysqldump.exe auf → SQL-Dump in Backups\DB\
|
||||
/// 2. Kopiert Logs\ → Backups\Logs\
|
||||
/// Falls mysqldump nicht gefunden: Warnung und Überspringen.
|
||||
/// </summary>
|
||||
public class BackupWorker : WorkerBase
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public override string Name => "BackupWorker";
|
||||
public override string Module => "Core";
|
||||
public override WorkerType Type => WorkerType.Worker;
|
||||
|
||||
protected override TimeSpan? Interval =>
|
||||
TimeSpan.FromMinutes(_settings.Settings.WorkerSettings.BackupWorker.IntervalMinutes);
|
||||
|
||||
public BackupWorker(LoggingService logger, DatabaseService db, SettingsService settings)
|
||||
: base(logger, db)
|
||||
{
|
||||
_settings = settings;
|
||||
Info.Active = settings.Settings.WorkerSettings.BackupWorker.Enabled;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm");
|
||||
var backupRoot = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Backups");
|
||||
var dbDir = Path.Combine(backupRoot, "DB");
|
||||
var logsDir = Path.Combine(backupRoot, "Logs");
|
||||
|
||||
Directory.CreateDirectory(dbDir);
|
||||
Directory.CreateDirectory(logsDir);
|
||||
|
||||
// ── 1. Datenbank-Dump ────────────────────────────────────────────────
|
||||
await RunMysqlDumpAsync(dbDir, timestamp, ct);
|
||||
|
||||
// ── 2. Logs-Backup ───────────────────────────────────────────────────
|
||||
CopyLogs(logsDir, timestamp);
|
||||
|
||||
Logger.Info(Module, $"Backup abgeschlossen: {timestamp}");
|
||||
}
|
||||
|
||||
// ─── mysqldump ────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task RunMysqlDumpAsync(string dbDir, string timestamp, CancellationToken ct)
|
||||
{
|
||||
var dump = FindMysqldump();
|
||||
if (dump == null)
|
||||
{
|
||||
Logger.Warn(Module, "mysqldump.exe nicht gefunden – DB-Backup übersprungen.");
|
||||
return;
|
||||
}
|
||||
|
||||
var db = _settings.Settings.Database;
|
||||
var dumpFile = Path.Combine(dbDir, $"{db.Database}_{timestamp}.sql");
|
||||
|
||||
var args = $"--host={db.Host} --port={db.Port} " +
|
||||
$"--user={db.User} --password={db.Password} " +
|
||||
$"--single-transaction --routines --triggers " +
|
||||
$"{db.Database} --result-file=\"{dumpFile}\"";
|
||||
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = dump,
|
||||
Arguments = args,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var proc = System.Diagnostics.Process.Start(psi)
|
||||
?? throw new InvalidOperationException("mysqldump konnte nicht gestartet werden.");
|
||||
|
||||
await proc.WaitForExitAsync(ct);
|
||||
|
||||
if (proc.ExitCode != 0)
|
||||
{
|
||||
var err = await proc.StandardError.ReadToEndAsync(ct);
|
||||
Logger.Warn(Module, $"mysqldump Exitcode {proc.ExitCode}: {err}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Info(Module, $"DB-Dump erstellt: {Path.GetFileName(dumpFile)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindMysqldump()
|
||||
{
|
||||
// Häufige Installationspfade auf Windows-Server
|
||||
var candidates = new[]
|
||||
{
|
||||
"mysqldump.exe",
|
||||
@"C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqldump.exe",
|
||||
@"C:\Program Files\MySQL\MySQL Server 8.4\bin\mysqldump.exe",
|
||||
@"C:\xampp\mysql\bin\mysqldump.exe"
|
||||
};
|
||||
|
||||
foreach (var c in candidates)
|
||||
if (File.Exists(c)) return c;
|
||||
|
||||
// PATH-Suche
|
||||
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
foreach (var dir in pathVar.Split(';'))
|
||||
{
|
||||
var full = Path.Combine(dir.Trim(), "mysqldump.exe");
|
||||
if (File.Exists(full)) return full;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Logs kopieren ────────────────────────────────────────────────────────
|
||||
|
||||
private void CopyLogs(string logsBackupDir, string timestamp)
|
||||
{
|
||||
try
|
||||
{
|
||||
var srcLogs = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
|
||||
if (!Directory.Exists(srcLogs)) return;
|
||||
|
||||
var destDir = Path.Combine(logsBackupDir, timestamp);
|
||||
CopyDirectory(srcLogs, destDir);
|
||||
Logger.Info(Module, $"Logs-Backup erstellt: {destDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn(Module, $"Logs-Backup fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string src, string dest)
|
||||
{
|
||||
Directory.CreateDirectory(dest);
|
||||
foreach (var file in Directory.GetFiles(src))
|
||||
File.Copy(file, Path.Combine(dest, Path.GetFileName(file)), overwrite: true);
|
||||
foreach (var dir in Directory.GetDirectories(src))
|
||||
CopyDirectory(dir, Path.Combine(dest, Path.GetFileName(dir)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using IBKRTrader.Core.Database;
|
||||
using IBKRTrader.Core.IBKR;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Settings;
|
||||
|
||||
namespace IBKRTrader.Core.Workers.BuiltIn;
|
||||
|
||||
/// <summary>
|
||||
/// Synchronisiert IBKR-Instrumentenstammdaten alle 30 Tage.
|
||||
/// </summary>
|
||||
public class IBKRInstrumentSyncWorker : WorkerBase
|
||||
{
|
||||
public override string Name => "IBKR Instrument Sync";
|
||||
public override string Module => "Core";
|
||||
public override WorkerType Type => WorkerType.Worker;
|
||||
|
||||
protected override TimeSpan? Interval =>
|
||||
TimeSpan.FromMinutes(_settings.Settings.WorkerSettings.InstrumentSyncWorker.IntervalMinutes);
|
||||
|
||||
private readonly SettingsService _settings;
|
||||
private readonly IBKRGatewayService _gateway;
|
||||
private readonly IBKRMarketDataRepository _repo;
|
||||
|
||||
public IBKRInstrumentSyncWorker(
|
||||
LoggingService logger, DatabaseService db,
|
||||
SettingsService settings, IBKRGatewayService gateway,
|
||||
IBKRMarketDataRepository repo)
|
||||
: base(logger, db)
|
||||
{
|
||||
_settings = settings;
|
||||
_gateway = gateway;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
if (!_settings.Settings.IBKRWebApi.Enabled)
|
||||
{
|
||||
Logger.Info(Module, "IBKR Web API deaktiviert – InstrumentSync übersprungen.");
|
||||
return;
|
||||
}
|
||||
|
||||
var authStatus = await _gateway.CheckAuthStatusAsync(ct);
|
||||
if (authStatus?.Authenticated != true)
|
||||
{
|
||||
Logger.Warn(Module, "IBKR Gateway nicht authentifiziert – InstrumentSync übersprungen.");
|
||||
return;
|
||||
}
|
||||
|
||||
await _gateway.TickleAsync(ct);
|
||||
await MapUnmappedTickersAsync(ct);
|
||||
await RefreshExistingInstrumentsAsync(ct);
|
||||
}
|
||||
|
||||
private async Task MapUnmappedTickersAsync(CancellationToken ct)
|
||||
{
|
||||
var unmapped = (await _repo.GetUnmappedTickersFromCongressTradesAsync()).ToList();
|
||||
if (unmapped.Count == 0) { Logger.Info(Module, "Keine neuen unmapped Ticker."); return; }
|
||||
|
||||
Logger.Info(Module, $"Mappe {unmapped.Count} neue Ticker auf IBKR-Contracts...");
|
||||
int mapped = 0, failed = 0;
|
||||
|
||||
foreach (var ticker in unmapped)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
try
|
||||
{
|
||||
var results = await _gateway.SearchContractBySymbolAsync(ticker, ct);
|
||||
if (results == null || results.Count == 0)
|
||||
{ Logger.Warn(Module, $"Kein Contract für: {ticker}"); failed++; continue; }
|
||||
|
||||
long conid = results[0].ConId;
|
||||
var existing = await _repo.GetInstrumentByConidAsync(conid);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
var info = await _gateway.GetContractInfoAsync(conid, ct);
|
||||
var instrument = new IBKRInstrument
|
||||
{
|
||||
IbkrConid = conid, Symbol = info?.Symbol ?? ticker,
|
||||
SecType = info?.InstrumentType ?? "STK",
|
||||
Exchange = info?.Exchange ?? "SMART",
|
||||
PrimaryExchange = info?.ListingExchange,
|
||||
Currency = info?.Currency ?? "USD",
|
||||
CompanyName = info?.CompanyName ?? results[0].CompanyName,
|
||||
Sector = info?.Sector, Industry = info?.Industry,
|
||||
Description = info?.Text, Active = true,
|
||||
LastFetched = DateTime.UtcNow
|
||||
};
|
||||
var id = await _repo.UpsertInstrumentAsync(instrument);
|
||||
await _repo.UpsertExternalIdentifierAsync(id, "capitoltrades", ticker);
|
||||
Logger.Info(Module, $"Neu: {ticker} → conid {conid}");
|
||||
}
|
||||
else
|
||||
{
|
||||
await _repo.UpsertExternalIdentifierAsync(existing.Id, "capitoltrades", ticker);
|
||||
}
|
||||
mapped++;
|
||||
}
|
||||
catch (Exception ex) { Logger.Error(Module, $"Mapping '{ticker}': {ex.Message}", ex); failed++; }
|
||||
}
|
||||
Logger.Info(Module, $"Mapping: {mapped} OK, {failed} Fehler.");
|
||||
}
|
||||
|
||||
private async Task RefreshExistingInstrumentsAsync(CancellationToken ct)
|
||||
{
|
||||
var instruments = (await _repo.GetAllActiveInstrumentsAsync()).ToList();
|
||||
if (instruments.Count == 0) return;
|
||||
|
||||
Logger.Info(Module, $"Aktualisiere {instruments.Count} Instrumente...");
|
||||
int ok = 0, err = 0;
|
||||
|
||||
foreach (var instr in instruments)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
try
|
||||
{
|
||||
var info = await _gateway.GetContractInfoAsync(instr.IbkrConid, ct);
|
||||
if (info == null) { err++; continue; }
|
||||
|
||||
instr.Symbol = info.Symbol ?? instr.Symbol;
|
||||
instr.CompanyName = info.CompanyName ?? instr.CompanyName;
|
||||
instr.Sector = info.Sector ?? instr.Sector;
|
||||
instr.Industry = info.Industry ?? instr.Industry;
|
||||
instr.LastFetched = DateTime.UtcNow;
|
||||
await _repo.UpsertInstrumentAsync(instr);
|
||||
ok++;
|
||||
}
|
||||
catch (Exception ex) { Logger.Error(Module, $"Update {instr.Symbol}: {ex.Message}", ex); err++; }
|
||||
}
|
||||
Logger.Info(Module, $"Stammdaten: {ok} OK, {err} Fehler.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using IBKRTrader.Core.Database;
|
||||
using IBKRTrader.Core.IBKR;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Settings;
|
||||
|
||||
namespace IBKRTrader.Core.Workers.BuiltIn;
|
||||
|
||||
/// <summary>
|
||||
/// Ruft täglich die Preishistorie (daily OHLCV) für alle aktiven
|
||||
/// IBKR-Instrumente ab und speichert sie in core_ibkr_market_data.
|
||||
///
|
||||
/// Ablauf:
|
||||
/// 1. Prüft ob IBKR Web API aktiviert und Gateway authentifiziert
|
||||
/// 2. Iteriert über alle aktiven Instrumente
|
||||
/// 3. Bestimmt den benötigten Zeitraum (initial: 2Y, danach: ab letztem Bar)
|
||||
/// 4. Ruft HMDS Historical Data ab und speichert via UPSERT
|
||||
/// </summary>
|
||||
public class IBKRPriceHistoryWorker : WorkerBase
|
||||
{
|
||||
public override string Name => "IBKR Price History";
|
||||
public override string Module => "Core";
|
||||
public override WorkerType Type => WorkerType.Worker;
|
||||
|
||||
protected override TimeSpan? Interval =>
|
||||
TimeSpan.FromMinutes(_settings.Settings.WorkerSettings.PriceHistoryWorker.IntervalMinutes);
|
||||
|
||||
private readonly SettingsService _settings;
|
||||
private readonly IBKRGatewayService _gateway;
|
||||
private readonly IBKRMarketDataRepository _repo;
|
||||
|
||||
public IBKRPriceHistoryWorker(
|
||||
LoggingService logger, DatabaseService db,
|
||||
SettingsService settings, IBKRGatewayService gateway,
|
||||
IBKRMarketDataRepository repo)
|
||||
: base(logger, db)
|
||||
{
|
||||
_settings = settings;
|
||||
_gateway = gateway;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
if (!_settings.Settings.IBKRWebApi.Enabled)
|
||||
{
|
||||
Logger.Info(Module, "IBKR Web API deaktiviert – PriceHistory übersprungen.");
|
||||
return;
|
||||
}
|
||||
|
||||
var authStatus = await _gateway.CheckAuthStatusAsync(ct);
|
||||
if (authStatus?.Authenticated != true)
|
||||
{
|
||||
Logger.Warn(Module, "IBKR Gateway nicht authentifiziert – PriceHistory übersprungen.");
|
||||
return;
|
||||
}
|
||||
|
||||
await _gateway.TickleAsync(ct);
|
||||
|
||||
var instruments = (await _repo.GetAllActiveInstrumentsAsync()).ToList();
|
||||
if (instruments.Count == 0)
|
||||
{
|
||||
Logger.Info(Module, "Keine aktiven Instrumente – PriceHistory übersprungen.");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.Info(Module, $"Starte Preishistorie-Abruf für {instruments.Count} Instrumente...");
|
||||
int updated = 0, errors = 0, skipped = 0;
|
||||
|
||||
for (int i = 0; i < instruments.Count; i++)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
var instr = instruments[i];
|
||||
try
|
||||
{
|
||||
// Benötigten Zeitraum bestimmen
|
||||
var latestBar = await _repo.GetLatestBarTimestampAsync(instr.Id);
|
||||
string period = DeterminePeriod(latestBar);
|
||||
|
||||
if (period == "SKIP")
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Historische Daten abrufen
|
||||
var histData = await _gateway.GetHistoricalDataAsync(
|
||||
instr.IbkrConid, period, "1d", outsideRth: false, ct: ct);
|
||||
|
||||
if (histData?.Data == null || histData.Data.Count == 0)
|
||||
{
|
||||
Logger.Warn(Module, $"Keine Daten für {instr.Symbol} (conid {instr.IbkrConid})");
|
||||
errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bars konvertieren und speichern
|
||||
var bars = ConvertBars(instr.Id, histData);
|
||||
await _repo.UpsertMarketDataBatchAsync(bars);
|
||||
|
||||
updated++;
|
||||
|
||||
if ((i + 1) % 25 == 0 || i == instruments.Count - 1)
|
||||
{
|
||||
Logger.Info(Module, $"Fortschritt: {i + 1}/{instruments.Count} " +
|
||||
$"({updated} OK, {errors} Fehler, {skipped} übersprungen)");
|
||||
// Session alive halten bei großen Mengen
|
||||
await _gateway.TickleAsync(ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(Module, $"Fehler bei {instr.Symbol}: {ex.Message}", ex);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
Logger.Info(Module, $"Preishistorie abgeschlossen: {updated} aktualisiert, " +
|
||||
$"{errors} Fehler, {skipped} übersprungen.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bestimmt den HMDS-Period-Parameter basierend auf dem letzten vorhandenen Bar.
|
||||
/// </summary>
|
||||
private string DeterminePeriod(DateTime? latestBar)
|
||||
{
|
||||
if (latestBar == null)
|
||||
{
|
||||
// Erster Import: verwende konfigurierten Zeitraum
|
||||
return _settings.Settings.IBKRWebApi.HistoryPeriod;
|
||||
}
|
||||
|
||||
var daysSinceLast = (DateTime.UtcNow - latestBar.Value).TotalDays;
|
||||
|
||||
if (daysSinceLast < 1) return "SKIP"; // Bereits aktuell
|
||||
if (daysSinceLast <= 7) return "1w";
|
||||
if (daysSinceLast <= 30) return "1m";
|
||||
if (daysSinceLast <= 90) return "3m";
|
||||
if (daysSinceLast <= 180) return "6m";
|
||||
if (daysSinceLast <= 365) return "1y";
|
||||
return "2y";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Konvertiert IBKR API-Bars in DB-Entities.
|
||||
/// </summary>
|
||||
private static List<IBKRMarketBar> ConvertBars(
|
||||
long instrumentId, IBKRHistoricalDataResponse response)
|
||||
{
|
||||
var bars = new List<IBKRMarketBar>();
|
||||
|
||||
if (response.Data == null) return bars;
|
||||
|
||||
foreach (var apiBar in response.Data)
|
||||
{
|
||||
// IBKR liefert Timestamp in Millisekunden seit Unix-Epoch
|
||||
var timestamp = DateTimeOffset
|
||||
.FromUnixTimeMilliseconds(apiBar.Timestamp)
|
||||
.UtcDateTime;
|
||||
|
||||
bars.Add(new IBKRMarketBar
|
||||
{
|
||||
InstrumentId = instrumentId,
|
||||
BarSize = "daily",
|
||||
Timestamp = timestamp,
|
||||
Open = apiBar.Open,
|
||||
High = apiBar.High,
|
||||
Low = apiBar.Low,
|
||||
Close = apiBar.Close,
|
||||
Volume = apiBar.Volume
|
||||
});
|
||||
}
|
||||
|
||||
return bars;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using IBKRTrader.Core.Database;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Settings;
|
||||
|
||||
namespace IBKRTrader.Core.Workers.BuiltIn;
|
||||
|
||||
/// <summary>
|
||||
/// WebApiService – permanenter REST-API-Service auf Basis von HttpListener.
|
||||
/// Basis-Routen: GET /api/status, GET /api/workers
|
||||
/// Wird später schrittweise um vollständige API-Endpunkte erweitert.
|
||||
/// </summary>
|
||||
public class WebApiService : WorkerBase
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
private WorkerEngine? _engine; // wird nach DI-Aufbau gesetzt (kein readonly)
|
||||
private HttpListener? _listener;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts =
|
||||
new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
public override string Name => "WebApiService";
|
||||
public override string Module => "Core";
|
||||
public override WorkerType Type => WorkerType.Service;
|
||||
|
||||
protected override TimeSpan? Interval => null;
|
||||
|
||||
public WebApiService(LoggingService logger, DatabaseService db, SettingsService settings)
|
||||
: base(logger, db)
|
||||
{
|
||||
_settings = settings;
|
||||
Info.Active = settings.Settings.Webserver.Enabled;
|
||||
Info.RunEvery = "Service";
|
||||
}
|
||||
|
||||
/// <summary>Setzt die WorkerEngine nach DI-Aufbau (zirkulare Abhängigkeit vermeiden).</summary>
|
||||
public void SetEngine(WorkerEngine engine) => _engine = engine;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
var port = _settings.Settings.Webserver.Port + 1; // API auf Port+1
|
||||
_listener = new HttpListener();
|
||||
_listener.Prefixes.Add($"http://localhost:{port}/api/");
|
||||
|
||||
try
|
||||
{
|
||||
_listener.Start();
|
||||
Logger.Info(Module, $"WebAPI gestartet auf http://localhost:{port}/api/");
|
||||
Info.Info = $"http://localhost:{port}/api/";
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var contextTask = _listener.GetContextAsync();
|
||||
var cancelTask = Task.Delay(Timeout.Infinite, ct);
|
||||
var completed = await Task.WhenAny(contextTask, cancelTask);
|
||||
if (completed == cancelTask) break;
|
||||
|
||||
var ctx = await contextTask;
|
||||
_ = RouteAsync(ctx);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_listener.Stop();
|
||||
Logger.Info(Module, "WebAPI gestoppt.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RouteAsync(HttpListenerContext ctx)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = ctx.Request.Url?.AbsolutePath.TrimEnd('/').ToLowerInvariant() ?? "";
|
||||
|
||||
object? result = path switch
|
||||
{
|
||||
"/api/status" => new { status = "ok", time = DateTime.UtcNow },
|
||||
"/api/workers" => _engine?.WorkerInfos
|
||||
.Select(w => new { w.WorkerName, w.Module, w.Type,
|
||||
w.Info, w.Active }) ?? [],
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
ctx.Response.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(result, JsonOpts);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
ctx.Response.ContentType = "application/json";
|
||||
ctx.Response.ContentLength64 = bytes.Length;
|
||||
ctx.Response.StatusCode = 200;
|
||||
await ctx.Response.OutputStream.WriteAsync(bytes);
|
||||
ctx.Response.Close();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using IBKRTrader.Core.Database;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Settings;
|
||||
|
||||
namespace IBKRTrader.Core.Workers.BuiltIn;
|
||||
|
||||
/// <summary>
|
||||
/// WebserverService – permanenter Service auf Basis von System.Net.HttpListener.
|
||||
/// Lauscht auf http://localhost:{port}/ und liefert einen Status-JSON.
|
||||
/// Wird später durch vollständige Web-UI erweitert.
|
||||
/// </summary>
|
||||
public class WebserverService : WorkerBase
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
private HttpListener? _listener;
|
||||
|
||||
public override string Name => "WebserverService";
|
||||
public override string Module => "Core";
|
||||
public override WorkerType Type => WorkerType.Service;
|
||||
|
||||
protected override TimeSpan? Interval => null; // Service = permanent
|
||||
|
||||
public WebserverService(LoggingService logger, DatabaseService db, SettingsService settings)
|
||||
: base(logger, db)
|
||||
{
|
||||
_settings = settings;
|
||||
Info.Active = settings.Settings.Webserver.Enabled;
|
||||
Info.RunEvery = "Service";
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
var port = _settings.Settings.Webserver.Port;
|
||||
_listener = new HttpListener();
|
||||
_listener.Prefixes.Add($"http://localhost:{port}/");
|
||||
|
||||
try
|
||||
{
|
||||
_listener.Start();
|
||||
Logger.Info(Module, $"Webserver gestartet auf http://localhost:{port}/");
|
||||
Info.Info = $"http://localhost:{port}/";
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
// GetContextAsync blockiert – abbrechen via ct
|
||||
var contextTask = _listener.GetContextAsync();
|
||||
var cancelTask = Task.Delay(Timeout.Infinite, ct);
|
||||
|
||||
var completed = await Task.WhenAny(contextTask, cancelTask);
|
||||
if (completed == cancelTask) break;
|
||||
|
||||
var ctx = await contextTask;
|
||||
_ = HandleRequestAsync(ctx); // fire-and-forget pro Request
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_listener.Stop();
|
||||
Logger.Info(Module, "Webserver gestoppt.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRequestAsync(HttpListenerContext ctx)
|
||||
{
|
||||
try
|
||||
{
|
||||
const string json = """{"status":"ok","app":"IBKRTrader","version":"1.0.0"}""";
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
ctx.Response.ContentType = "application/json";
|
||||
ctx.Response.ContentLength64 = bytes.Length;
|
||||
ctx.Response.StatusCode = 200;
|
||||
|
||||
await ctx.Response.OutputStream.WriteAsync(bytes);
|
||||
ctx.Response.Close();
|
||||
}
|
||||
catch { /* Client hat getrennt */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace IBKRTrader.Core.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Vertrag für jeden Worker oder Service im System.
|
||||
/// Core und Module müssen dieses Interface implementieren.
|
||||
/// </summary>
|
||||
public interface IWorker
|
||||
{
|
||||
/// <summary>Anzeigename in dgv_workerlist.</summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>Modul-Kürzel (z. B. "Core", "CT").</summary>
|
||||
string Module { get; }
|
||||
|
||||
/// <summary>Worker = periodisch / Service = dauerhaft.</summary>
|
||||
WorkerType Type { get; }
|
||||
|
||||
/// <summary>Live-Daten für die DataGridView-Zeile.</summary>
|
||||
WorkerInfo Info { get; }
|
||||
|
||||
/// <summary>Startet den Worker/Service asynchron.</summary>
|
||||
Task StartAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>Stoppt den Worker/Service sauber.</summary>
|
||||
Task StopAsync();
|
||||
|
||||
/// <summary>Löst einen sofortigen, manuellen Run aus (unabhängig vom Zeitplan).</summary>
|
||||
Task TriggerAsync();
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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 Db.BeginWorkerLogAsync(Name, Module);
|
||||
await ExecuteAsync(ct);
|
||||
await Db.EndWorkerLogAsync(logId, true);
|
||||
|
||||
Info.LastRuntime = DateTime.Now;
|
||||
Info.Status = WorkerStatus.Idle;
|
||||
Info.Info = $"OK – {Info.LastRuntime:HH:mm:ss}";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (logId > 0)
|
||||
await Db.EndWorkerLogAsync(logId, false, "Abgebrochen");
|
||||
Info.Status = WorkerStatus.Stopped;
|
||||
Info.Info = "Abgebrochen";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (logId > 0)
|
||||
await Db.EndWorkerLogAsync(logId, false, ex.Message);
|
||||
Logger.Error(Module, $"Fehler in Worker {Name}: {ex.Message}", ex);
|
||||
Info.Status = WorkerStatus.Error;
|
||||
Info.Info = $"Fehler: {ex.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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.ComponentModel;
|
||||
using IBKRTrader.Core.Logging;
|
||||
|
||||
namespace IBKRTrader.Core.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestriert alle registrierten IWorker.
|
||||
/// – Startet/stoppt Worker
|
||||
/// – Stellt BindingList für DataGridView bereit
|
||||
/// – Thread-sicher via ConcurrentDictionary
|
||||
/// </summary>
|
||||
public class WorkerEngine
|
||||
{
|
||||
private readonly IEnumerable<IWorker> _workers;
|
||||
private readonly LoggingService _logger;
|
||||
private readonly ConcurrentDictionary<string, IWorker> _registry = new();
|
||||
private CancellationTokenSource _cts = new();
|
||||
|
||||
/// <summary>Live-bindbare Liste für dgv_workerlist.</summary>
|
||||
public BindingList<WorkerInfo> WorkerInfos { get; } = [];
|
||||
|
||||
public WorkerEngine(IEnumerable<IWorker> workers, LoggingService logger)
|
||||
{
|
||||
_workers = workers;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ─── Engine-Lifecycle ─────────────────────────────────────────────────────
|
||||
|
||||
public async Task StartAllAsync()
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
foreach (var worker in _workers)
|
||||
{
|
||||
RegisterWorker(worker);
|
||||
|
||||
if (worker.Info.Active)
|
||||
await worker.StartAsync(_cts.Token);
|
||||
}
|
||||
|
||||
_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}");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace IBKRTrader.Core.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel-Objekt für eine Zeile in dgv_workerlist.
|
||||
/// Implementiert INotifyPropertyChanged für automatisches DataGridView-Binding.
|
||||
/// </summary>
|
||||
public class WorkerInfo : INotifyPropertyChanged
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private bool _active;
|
||||
private string _type = "";
|
||||
private string _module = "";
|
||||
private string _workerName = "";
|
||||
private DateTime? _lastRuntime;
|
||||
private DateTime? _nextRuntime;
|
||||
private string _runEvery = "";
|
||||
private string _info = "";
|
||||
private WorkerStatus _status = WorkerStatus.Idle;
|
||||
|
||||
// ─── Properties ───────────────────────────────────────────────────────────
|
||||
|
||||
public bool Active { get => _active; set => Set(ref _active, value); }
|
||||
public string Type { get => _type; set => Set(ref _type, value); }
|
||||
public string Module { get => _module; set => Set(ref _module, value); }
|
||||
public string WorkerName { get => _workerName; set => Set(ref _workerName, value); }
|
||||
public DateTime? LastRuntime { get => _lastRuntime; set => Set(ref _lastRuntime, value); }
|
||||
public DateTime? NextRuntime { get => _nextRuntime; set => Set(ref _nextRuntime, value); }
|
||||
public string RunEvery { get => _runEvery; set => Set(ref _runEvery, value); }
|
||||
public string Info { get => _info; set => Set(ref _info, value); }
|
||||
|
||||
/// <summary>Interner Status – wird nicht direkt als DGV-Spalte verwendet,
|
||||
/// aber steuert die Info-Spalte.</summary>
|
||||
public WorkerStatus Status
|
||||
{
|
||||
get => _status;
|
||||
set
|
||||
{
|
||||
if (!Set(ref _status, value)) return;
|
||||
// Info-Text automatisch synchronisieren
|
||||
if (value == WorkerStatus.Idle && string.IsNullOrWhiteSpace(_info))
|
||||
Info = "Bereit";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hilfsmethode ─────────────────────────────────────────────────────────
|
||||
|
||||
private bool Set<T>(ref T field, T value, [CallerMemberName] string? prop = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace IBKRTrader.Core.Workers;
|
||||
|
||||
public enum WorkerStatus
|
||||
{
|
||||
Idle,
|
||||
Running,
|
||||
Error,
|
||||
Stopped,
|
||||
Disabled
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace IBKRTrader.Core.Workers;
|
||||
|
||||
public enum WorkerType
|
||||
{
|
||||
/// <summary>Läuft periodisch nach einem festen Zeitplan.</summary>
|
||||
Worker,
|
||||
|
||||
/// <summary>Läuft permanent (z. B. Webserver, WebAPI).</summary>
|
||||
Service
|
||||
}
|
||||
Reference in New Issue
Block a user