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:
Richard
2026-07-26 18:19:47 +02:00
co-authored by Claude Opus 4.8
commit ebeb035e92
47 changed files with 4527 additions and 0 deletions
+146
View File
@@ -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;
}
}
+104
View File
@@ -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 { }
}
}
+81
View File
@@ -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 */ }
}
}