From ebeb035e9206fd086c1dcb3fbc9acba44a60fa3c Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 26 Jul 2026 18:19:47 +0200 Subject: [PATCH] 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 --- .agents/rules/grundregeln.md | 49 +++ .gitignore | 47 +++ Core/AI/AIModelService.cs | 24 ++ Core/Budget/BudgetService.cs | 48 +++ Core/Database/DatabaseService.cs | 119 +++++++ Core/Database/Migrations/CoreMigrations.cs | 79 ++++ Core/Database/Migrations/IBKRMigrations.cs | 85 +++++ Core/IBKR/IBKRGatewayService.cs | 274 ++++++++++++++ Core/IBKR/IBKRMarketDataRepository.cs | 152 ++++++++ Core/IBKR/IBKRModels.cs | 336 ++++++++++++++++++ Core/Logging/AppLogLevel.cs | 8 + Core/Logging/LogEntry.cs | 10 + Core/Logging/LoggingService.cs | 105 ++++++ Core/Settings/AppSettings.cs | 207 +++++++++++ Core/Settings/SettingsService.cs | 49 +++ Core/Trading/TradeHistoryService.cs | 47 +++ Core/Workers/BuiltIn/BackupWorker.cs | 146 ++++++++ .../BuiltIn/IBKRInstrumentSyncWorker.cs | 133 +++++++ .../Workers/BuiltIn/IBKRPriceHistoryWorker.cs | 176 +++++++++ Core/Workers/BuiltIn/WebApiService.cs | 104 ++++++ Core/Workers/BuiltIn/WebserverService.cs | 81 +++++ Core/Workers/IWorker.cs | 29 ++ Core/Workers/WorkerBase.cs | 162 +++++++++ Core/Workers/WorkerEngine.cs | 96 +++++ Core/Workers/WorkerInfo.cs | 58 +++ Core/Workers/WorkerStatus.cs | 10 + Core/Workers/WorkerType.cs | 10 + Form1.Designer.cs | 260 ++++++++++++++ Form1.cs | 131 +++++++ Form1.resx | 150 ++++++++ IBKRTrader.csproj | 28 ++ IBKRTrader.slnx | 3 + .../CongressTrading/CongressTradingModule.cs | 55 +++ .../Database/CongressMigrations.cs | 80 +++++ .../Database/CongressRepository.cs | 174 +++++++++ .../CongressTrading/Models/CongressMember.cs | 15 + .../CongressTrading/Models/CongressTrade.cs | 23 ++ .../Scraper/CapitolTradesScraper.cs | 332 +++++++++++++++++ .../Workers/CongressHistoryImportWorker.cs | 138 +++++++ .../Workers/CongressScrapeWorker.cs | 82 +++++ Program.cs | 85 +++++ UI/LogPanelController.cs | 33 ++ UI/WorkerListBindingSource.cs | 77 ++++ analyze_ct.ps1 | 58 +++ ct_raw.html | 1 + grok.txt | 129 +++++++ settings.example.json | 29 ++ 47 files changed, 4527 insertions(+) create mode 100644 .agents/rules/grundregeln.md create mode 100644 .gitignore create mode 100644 Core/AI/AIModelService.cs create mode 100644 Core/Budget/BudgetService.cs create mode 100644 Core/Database/DatabaseService.cs create mode 100644 Core/Database/Migrations/CoreMigrations.cs create mode 100644 Core/Database/Migrations/IBKRMigrations.cs create mode 100644 Core/IBKR/IBKRGatewayService.cs create mode 100644 Core/IBKR/IBKRMarketDataRepository.cs create mode 100644 Core/IBKR/IBKRModels.cs create mode 100644 Core/Logging/AppLogLevel.cs create mode 100644 Core/Logging/LogEntry.cs create mode 100644 Core/Logging/LoggingService.cs create mode 100644 Core/Settings/AppSettings.cs create mode 100644 Core/Settings/SettingsService.cs create mode 100644 Core/Trading/TradeHistoryService.cs create mode 100644 Core/Workers/BuiltIn/BackupWorker.cs create mode 100644 Core/Workers/BuiltIn/IBKRInstrumentSyncWorker.cs create mode 100644 Core/Workers/BuiltIn/IBKRPriceHistoryWorker.cs create mode 100644 Core/Workers/BuiltIn/WebApiService.cs create mode 100644 Core/Workers/BuiltIn/WebserverService.cs create mode 100644 Core/Workers/IWorker.cs create mode 100644 Core/Workers/WorkerBase.cs create mode 100644 Core/Workers/WorkerEngine.cs create mode 100644 Core/Workers/WorkerInfo.cs create mode 100644 Core/Workers/WorkerStatus.cs create mode 100644 Core/Workers/WorkerType.cs create mode 100644 Form1.Designer.cs create mode 100644 Form1.cs create mode 100644 Form1.resx create mode 100644 IBKRTrader.csproj create mode 100644 IBKRTrader.slnx create mode 100644 Modules/CongressTrading/CongressTradingModule.cs create mode 100644 Modules/CongressTrading/Database/CongressMigrations.cs create mode 100644 Modules/CongressTrading/Database/CongressRepository.cs create mode 100644 Modules/CongressTrading/Models/CongressMember.cs create mode 100644 Modules/CongressTrading/Models/CongressTrade.cs create mode 100644 Modules/CongressTrading/Scraper/CapitolTradesScraper.cs create mode 100644 Modules/CongressTrading/Workers/CongressHistoryImportWorker.cs create mode 100644 Modules/CongressTrading/Workers/CongressScrapeWorker.cs create mode 100644 Program.cs create mode 100644 UI/LogPanelController.cs create mode 100644 UI/WorkerListBindingSource.cs create mode 100644 analyze_ct.ps1 create mode 100644 ct_raw.html create mode 100644 grok.txt create mode 100644 settings.example.json diff --git a/.agents/rules/grundregeln.md b/.agents/rules/grundregeln.md new file mode 100644 index 0000000..7b4d59d --- /dev/null +++ b/.agents/rules/grundregeln.md @@ -0,0 +1,49 @@ +--- +trigger: always_on +--- + +Projektname: IBKRTrader +Ziel: Modulares, hochperformantes C# Trading-Framework (.NET 8) für automatische Aktien-Strategien mit Interactive Brokers API. +Strikte Architektur-Regeln (immer einhalten): + +Harter Core + beliebig viele unabhängige Module +Module dürfen Core oder andere Module niemals beeinflussen +DB-Tabellen-Namensschema: {ModulKürzel}_Tabellenname +→ Core = core_xxx +→ CongressTrading = ct_xxx +Vollständig modulare Worker-Engine: Jeder Worker ist komplett unabhängig, hat eigenen Zeitplan, ist einzeln aktivierbar/deaktivierbar/manuell startbar +Keine gegenseitigen Blockierungen – alles thread-sicher und performant + +Technik (fest): + +C# .NET 8 WinForms +MySQL (bergisnu_db0 / bergisnu_0 / o5/A49+f}:Da / larx.your-database.de) +IBKR TWS/Gateway API +Interne REST-API + lokaler Webserver (für späteres Web-UI) +Settings: settings.json + SettingsWorker.json +Logging: RichTextBox (rtb_logs) + Dateien unter Logs\[Modul]\[Level]-dd-MM-yy.txt (Info/Warn/Error) + +Bereits vorhandene UI-Elemente (müssen verwendet werden): + +TabControl mit Tabs: Dashboard, Trade History, Settings (PropertyGrid), Logs (rtb_logs), Workers/Services (dgv_workerlist), CongressTrading +dgv_workerlist Spalten: Active | Type | Module | Workername | Last Runtime | Next Runtime | Run Every | Info +Type = "Worker" oder "Service" (Service = permanent laufend) + +Core-Worker (müssen zuerst): + +Backup (alle 30 min) +Webserver (Service) +WebAPI (Service) + +Erstes Modul: CongressTrading +→ Scrapt https://www.capitoltrades.com/trades?pageSize=96 +→ Tabellen: ct_congressMember + ct_trade +→ Zuerst alle Trades der letzten 3 Jahre, danach Worker alle 30 min neue Trades +Entwicklungs-Regeln: + +Immer extrem saubere, modulare, wartbare Architektur +Build-Ordner muss absolut clean sein (nur notwendige Dateien) +Arbeite streng schrittweise: zuerst Core fertig, dann Module +Jede neue Funktion zuerst als Worker/Service im Core oder im jeweiligen Modul anlegen + +Diese Rule hat immer höchste Priorität. Bei jedem Prompt und jedem Neustart gelten diese Vorgaben. \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..456afe4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +## .NET / Visual Studio build output +bin/ +obj/ +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Bb]uild/ +[Bb]in/ +[Oo]bj/ + +## Visual Studio / Rider IDE +.vs/ +.idea/ +*.user +*.suo +*.userosscache +*.sln.docstates +*.csproj.user + +## NuGet +*.nupkg +packages/ +project.lock.json +project.fragment.lock.json + +## Test / coverage results +[Tt]est[Rr]esult*/ +[Cc]overage*/ + +## OS files +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +.DS_Store + +## Application data (do NOT commit secrets or runtime data) +settings.json +Backups/ +Logs/ +*.log + +## Local Claude / agent config +.claude/settings.local.json diff --git a/Core/AI/AIModelService.cs b/Core/AI/AIModelService.cs new file mode 100644 index 0000000..7d91c4b --- /dev/null +++ b/Core/AI/AIModelService.cs @@ -0,0 +1,24 @@ +using IBKRTrader.Core.Logging; + +namespace IBKRTrader.Core.AI; + +/// +/// Platzhalter für KI-Modell-Integration. +/// Wird in einer späteren Phase mit echten ML-Modellen ersetzt. +/// +public class AIModelService +{ + private readonly LoggingService _logger; + + public AIModelService(LoggingService logger) + { + _logger = logger; + } + + /// Platzhalter: Gibt immer 0.5 zurück (keine echte Vorhersage). + public Task PredictAsync(string symbol, object features) + { + _logger.Warn("AI", $"AIModelService ist ein Platzhalter – keine echte Vorhersage für {symbol}."); + return Task.FromResult(0.5); + } +} diff --git a/Core/Budget/BudgetService.cs b/Core/Budget/BudgetService.cs new file mode 100644 index 0000000..20b4cbb --- /dev/null +++ b/Core/Budget/BudgetService.cs @@ -0,0 +1,48 @@ +using IBKRTrader.Core.Database; +using IBKRTrader.Core.Logging; + +namespace IBKRTrader.Core.Budget; + +/// +/// Verwaltet das Budget pro Modul. +/// Liest/schreibt core_budget. +/// +public class BudgetService +{ + private readonly DatabaseService _db; + private readonly LoggingService _logger; + + public BudgetService(DatabaseService db, LoggingService logger) + { + _db = db; + _logger = logger; + } + + public async Task GetAvailableBudgetAsync(string module) + { + var row = await _db.QueryFirstOrDefaultAsync( + "SELECT total_budget, used_budget FROM `core_budget` WHERE module = @module", + new { module }); + + if (row == null) return 0; + return (decimal)row.total_budget - (decimal)row.used_budget; + } + + public async Task ReserveBudgetAsync(string module, decimal amount) + { + await _db.ExecuteAsync( + @"INSERT INTO `core_budget` (module, used_budget) + VALUES (@module, @amount) + ON DUPLICATE KEY UPDATE used_budget = used_budget + @amount", + new { module, amount }); + } + + public async Task ReleaseBudgetAsync(string module, decimal amount) + { + await _db.ExecuteAsync( + @"UPDATE `core_budget` + SET used_budget = GREATEST(0, used_budget - @amount) + WHERE module = @module", + new { module, amount }); + } +} diff --git a/Core/Database/DatabaseService.cs b/Core/Database/DatabaseService.cs new file mode 100644 index 0000000..f1d9d59 --- /dev/null +++ b/Core/Database/DatabaseService.cs @@ -0,0 +1,119 @@ +using Dapper; +using MySqlConnector; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Settings; + +namespace IBKRTrader.Core.Database; + +/// +/// Zentraler Datenbankzugriff via MySqlConnector + Dapper. +/// Jede Methode öffnet eine eigene Connection (Connection-Pooling via MySqlConnector). +/// +public class DatabaseService +{ + private readonly SettingsService _settings; + private readonly LoggingService _logger; + + public DatabaseService(SettingsService settings, LoggingService logger) + { + _settings = settings; + _logger = logger; + + // Dapper support for DateOnly + SqlMapper.AddTypeHandler(new DateOnlyTypeHandler()); + } + + private class DateOnlyTypeHandler : SqlMapper.TypeHandler + { + public override void SetValue(System.Data.IDbDataParameter parameter, DateOnly value) + { + parameter.Value = value.ToString("yyyy-MM-dd"); + } + + public override DateOnly Parse(object value) + { + if (value is DateTime dt) return DateOnly.FromDateTime(dt); + if (value is string s && DateOnly.TryParse(s, out var d)) return d; + return DateOnly.FromDateTime(Convert.ToDateTime(value)); + } + } + + + // ─── Connection ─────────────────────────────────────────────────────────── + + public MySqlConnection CreateConnection() + => new(_settings.Settings.Database.BuildConnectionString()); + + public async Task TestConnectionAsync() + { + try + { + await using var conn = CreateConnection(); + await conn.OpenAsync(); + _logger.Info("Core", "Datenbankverbindung erfolgreich hergestellt."); + return true; + } + catch (Exception ex) + { + _logger.Error("Core", "Datenbankverbindung fehlgeschlagen.", ex); + return false; + } + } + + // ─── Abfragen (Dapper) ──────────────────────────────────────────────────── + + public async Task> QueryAsync(string sql, object? param = null) + { + await using var conn = CreateConnection(); + return await conn.QueryAsync(sql, param); + } + + public async Task QueryFirstOrDefaultAsync(string sql, object? param = null) + { + await using var conn = CreateConnection(); + return await conn.QueryFirstOrDefaultAsync(sql, param); + } + + public async Task ExecuteAsync(string sql, object? param = null) + { + await using var conn = CreateConnection(); + return await conn.ExecuteAsync(sql, param); + } + + public async Task ExecuteScalarAsync(string sql, object? param = null) + { + await using var conn = CreateConnection(); +#pragma warning disable CS8603 + return await conn.ExecuteScalarAsync(sql, param); +#pragma warning restore CS8603 + } + + // ─── Worker-Log Hilfsmethoden ───────────────────────────────────────────── + + /// Legt einen neuen Worker-Log-Eintrag an und gibt die neue ID zurück. + public async Task BeginWorkerLogAsync(string workerName, string module) + { + const string sql = + @"INSERT INTO `core_worker_log` (worker_name, module, started_at, status) + VALUES (@workerName, @module, @now, 'Running'); + SELECT LAST_INSERT_ID();"; + return await ExecuteScalarAsync(sql, + new { workerName, module, now = DateTime.UtcNow }); + } + + /// Schließt einen Worker-Log-Eintrag ab (Success oder Error). + public async Task EndWorkerLogAsync(long logId, bool success, string? message = null) + { + const string sql = + @"UPDATE `core_worker_log` + SET finished_at = @now, status = @status, message = @msg + WHERE id = @id"; + await ExecuteAsync(sql, new + { + id = logId, + now = DateTime.UtcNow, + status = success ? "Success" : "Error", + msg = message + }); + } +} diff --git a/Core/Database/Migrations/CoreMigrations.cs b/Core/Database/Migrations/CoreMigrations.cs new file mode 100644 index 0000000..23cb85b --- /dev/null +++ b/Core/Database/Migrations/CoreMigrations.cs @@ -0,0 +1,79 @@ +using IBKRTrader.Core.Logging; + +namespace IBKRTrader.Core.Database.Migrations; + +/// +/// Erstellt alle core_xxx-Tabellen idempotent (IF NOT EXISTS). +/// Wird einmalig beim App-Start ausgeführt. +/// +public class CoreMigrations +{ + private readonly DatabaseService _db; + private readonly LoggingService _logger; + + public CoreMigrations(DatabaseService db, LoggingService logger) + { + _db = db; + _logger = logger; + } + + public async Task RunAsync() + { + _logger.Info("Core", "Starte Core-Datenbankmigrationen..."); + await CreateCoreSettingsAsync(); + await CreateCoreWorkerLogAsync(); + await CreateCoreTradeHistoryAsync(); + await CreateCoreBudgetAsync(); + _logger.Info("Core", "Core-Migrationen abgeschlossen."); + } + + // ─── Tabellen ───────────────────────────────────────────────────────────── + + private Task CreateCoreSettingsAsync() => _db.ExecuteAsync(@" + CREATE TABLE IF NOT EXISTS `core_settings` ( + `key` VARCHAR(100) NOT NULL PRIMARY KEY, + `value` TEXT, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); + + private Task CreateCoreWorkerLogAsync() => _db.ExecuteAsync(@" + CREATE TABLE IF NOT EXISTS `core_worker_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `worker_name` VARCHAR(100) NOT NULL, + `module` VARCHAR(50) NOT NULL DEFAULT 'Core', + `started_at` DATETIME, + `finished_at` DATETIME, + `status` ENUM('Running','Success','Error') DEFAULT 'Running', + `message` TEXT, + INDEX `idx_worker` (`worker_name`, `started_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); + + private Task CreateCoreTradeHistoryAsync() => _db.ExecuteAsync(@" + CREATE TABLE IF NOT EXISTS `core_trade_history` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `module` VARCHAR(50) NOT NULL, + `symbol` VARCHAR(20) NOT NULL, + `action` ENUM('BUY','SELL') NOT NULL, + `quantity` DECIMAL(18,4), + `price` DECIMAL(18,4), + `total_value` DECIMAL(18,4), + `traded_at` DATETIME, + `ibkr_order_id` VARCHAR(100), + `status` VARCHAR(50), + `notes` TEXT, + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_symbol` (`symbol`), + INDEX `idx_module` (`module`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); + + private Task CreateCoreBudgetAsync() => _db.ExecuteAsync(@" + CREATE TABLE IF NOT EXISTS `core_budget` ( + `module` VARCHAR(50) NOT NULL PRIMARY KEY, + `total_budget` DECIMAL(18,2) DEFAULT 0, + `used_budget` DECIMAL(18,2) DEFAULT 0, + `max_per_trade` DECIMAL(18,2) DEFAULT 0, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); +} diff --git a/Core/Database/Migrations/IBKRMigrations.cs b/Core/Database/Migrations/IBKRMigrations.cs new file mode 100644 index 0000000..7a24eda --- /dev/null +++ b/Core/Database/Migrations/IBKRMigrations.cs @@ -0,0 +1,85 @@ +using IBKRTrader.Core.Logging; + +namespace IBKRTrader.Core.Database.Migrations; + +/// +/// Erstellt die core_ibkr_xxx-Tabellen idempotent (IF NOT EXISTS). +/// Wird beim App-Start nach CoreMigrations ausgeführt. +/// +public class IBKRMigrations +{ + private readonly DatabaseService _db; + private readonly LoggingService _logger; + + public IBKRMigrations(DatabaseService db, LoggingService logger) + { + _db = db; + _logger = logger; + } + + public async Task RunAsync() + { + _logger.Info("Core", "Starte IBKR-Datenbankmigrationen..."); + await CreateInstrumentsTableAsync(); + await CreateMarketDataTableAsync(); + await CreateExternalIdentifiersTableAsync(); + _logger.Info("Core", "IBKR-Migrationen abgeschlossen."); + } + + // ─── Tabellen ───────────────────────────────────────────────────────────── + + private Task CreateInstrumentsTableAsync() => _db.ExecuteAsync(@" + CREATE TABLE IF NOT EXISTS `core_ibkr_instruments` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `ibkr_conid` BIGINT NOT NULL UNIQUE, + `symbol` VARCHAR(20) NOT NULL, + `sec_type` VARCHAR(10) NOT NULL DEFAULT 'STK', + `exchange` VARCHAR(20) NOT NULL DEFAULT 'SMART', + `primary_exchange` VARCHAR(20), + `currency` VARCHAR(5) NOT NULL DEFAULT 'USD', + `company_name` TEXT, + `isin` VARCHAR(12), + `sector` VARCHAR(100), + `industry` VARCHAR(100), + `description` TEXT, + `active` TINYINT(1) NOT NULL DEFAULT 1, + `last_fetched` DATETIME, + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_symbol` (`symbol`), + INDEX `idx_conid` (`ibkr_conid`), + INDEX `idx_active` (`active`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); + + private Task CreateMarketDataTableAsync() => _db.ExecuteAsync(@" + CREATE TABLE IF NOT EXISTS `core_ibkr_market_data` ( + `instrument_id` BIGINT NOT NULL, + `bar_size` VARCHAR(10) NOT NULL DEFAULT 'daily', + `timestamp` DATETIME NOT NULL, + `open` DECIMAL(12,6), + `high` DECIMAL(12,6), + `low` DECIMAL(12,6), + `close` DECIMAL(12,6), + `volume` BIGINT, + `wap` DECIMAL(12,6), + `bar_count` INT, + PRIMARY KEY (`instrument_id`, `bar_size`, `timestamp`), + INDEX `idx_timestamp` (`timestamp`), + CONSTRAINT `fk_md_instrument` FOREIGN KEY (`instrument_id`) + REFERENCES `core_ibkr_instruments` (`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); + + private Task CreateExternalIdentifiersTableAsync() => _db.ExecuteAsync(@" + CREATE TABLE IF NOT EXISTS `core_ibkr_external_identifiers` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `instrument_id` BIGINT NOT NULL, + `source` VARCHAR(50) NOT NULL, + `ticker` VARCHAR(50) NOT NULL, + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uq_mapping` (`instrument_id`, `source`, `ticker`), + INDEX `idx_source_ticker` (`source`, `ticker`), + CONSTRAINT `fk_ext_instrument` FOREIGN KEY (`instrument_id`) + REFERENCES `core_ibkr_instruments` (`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); +} diff --git a/Core/IBKR/IBKRGatewayService.cs b/Core/IBKR/IBKRGatewayService.cs new file mode 100644 index 0000000..af5e406 --- /dev/null +++ b/Core/IBKR/IBKRGatewayService.cs @@ -0,0 +1,274 @@ +using System.Net.Http.Json; +using System.Text.Json; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Settings; + +namespace IBKRTrader.Core.IBKR; + +/// +/// HTTP-Client für die IBKR Client Portal Web API (Gateway). +/// Alle API-Aufrufe laufen über den lokalen Client Portal Gateway +/// (Standard: https://localhost:5000/v1/api). +/// +/// Voraussetzungen: +/// - Client Portal Gateway muss laufen (Java-Prozess) +/// - Session muss per Browser + 2FA authentifiziert sein +/// +public class IBKRGatewayService +{ + private readonly SettingsService _settings; + private readonly LoggingService _logger; + private readonly HttpClient _http; + private readonly SemaphoreSlim _rateLimiter; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true + }; + + public bool IsConnected { get; private set; } + + public IBKRGatewayService(SettingsService settings, LoggingService logger) + { + _settings = settings; + _logger = logger; + + // HttpClient mit SSL-Bypass für localhost (Gateway hat kein signiertes Zertifikat) + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + + var apiSettings = _settings.Settings.IBKRWebApi; + _http = new HttpClient(handler) + { + BaseAddress = new Uri(apiSettings.GatewayUrl.TrimEnd('/') + "/v1/api/"), + Timeout = TimeSpan.FromSeconds(30) + }; + + // Standard-Header setzen + _http.DefaultRequestHeaders.Add("User-Agent", "IBKRTrader/1.0"); + _http.DefaultRequestHeaders.Add("Accept", "*/*"); + + // Rate-Limiter: maximal N gleichzeitige Anfragen (wird seriell mit Delay verwendet) + _rateLimiter = new SemaphoreSlim(1, 1); + } + + // ─── Rate Limiting ─────────────────────────────────────────────────────── + + private async Task RateLimitAsync(CancellationToken ct) + { + await _rateLimiter.WaitAsync(ct); + try + { + var delayMs = 1000 / Math.Max(1, _settings.Settings.IBKRWebApi.MaxRequestsPerSecond); + await Task.Delay(delayMs, ct); + } + finally + { + _rateLimiter.Release(); + } + } + + // ─── Session / Auth ────────────────────────────────────────────────────── + + /// + /// Prüft den Authentifizierungsstatus der Gateway-Session. + /// + public async Task CheckAuthStatusAsync(CancellationToken ct = default) + { + try + { + await RateLimitAsync(ct); + var response = await _http.PostAsync("iserver/auth/status", null, ct); + if (!response.IsSuccessStatusCode) + { + _logger.Warn("IBKR", $"Auth-Status-Check fehlgeschlagen: HTTP {(int)response.StatusCode}"); + return null; + } + + var result = await response.Content.ReadFromJsonAsync(JsonOpts, ct); + IsConnected = result?.Authenticated == true; + return result; + } + catch (HttpRequestException ex) + { + _logger.Warn("IBKR", $"Gateway nicht erreichbar: {ex.Message}"); + IsConnected = false; + return null; + } + catch (TaskCanceledException) + { + return null; + } + } + + /// + /// Hält die Session am Leben (Ping). Sollte alle ~60 Sekunden aufgerufen werden. + /// + public async Task TickleAsync(CancellationToken ct = default) + { + try + { + await RateLimitAsync(ct); + await _http.PostAsync("tickle", null, ct); + } + catch (Exception ex) + { + _logger.Warn("IBKR", $"Tickle fehlgeschlagen: {ex.Message}"); + } + } + + /// + /// Initialisiert die Brokerage-Session über den Gateway. + /// + public async Task InitBrokerageSessionAsync(CancellationToken ct = default) + { + try + { + await RateLimitAsync(ct); + var body = new StringContent("{\"publish\":true,\"compete\":true}", + System.Text.Encoding.UTF8, "application/json"); + var response = await _http.PostAsync("iserver/auth/ssodh/init", body, ct); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + _logger.Warn("IBKR", $"Brokerage-Session-Init fehlgeschlagen: {ex.Message}"); + return false; + } + } + + // ─── Contract Search ───────────────────────────────────────────────────── + + /// + /// Sucht einen Contract anhand des Symbols. + /// POST /iserver/secdef/search + /// Body: {"symbol":"AAPL"} + /// + public async Task?> SearchContractBySymbolAsync( + string symbol, CancellationToken ct = default) + { + try + { + await RateLimitAsync(ct); + var body = JsonContent.Create(new { symbol }); + var response = await _http.PostAsync("iserver/secdef/search", body, ct); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(ct); + _logger.Warn("IBKR", $"Contract-Suche für '{symbol}' fehlgeschlagen: HTTP {(int)response.StatusCode} – {errorBody}"); + return null; + } + + return await response.Content.ReadFromJsonAsync>(JsonOpts, ct); + } + catch (Exception ex) + { + _logger.Error("IBKR", $"Contract-Suche für '{symbol}' fehlgeschlagen: {ex.Message}", ex); + return null; + } + } + + /// + /// Ruft detaillierte Contract-Informationen ab. + /// GET /iserver/contract/{conid}/info + /// + public async Task GetContractInfoAsync( + long conid, CancellationToken ct = default) + { + try + { + await RateLimitAsync(ct); + var response = await _http.GetAsync($"iserver/contract/{conid}/info", ct); + + if (!response.IsSuccessStatusCode) + { + _logger.Warn("IBKR", $"Contract-Info für conid {conid} fehlgeschlagen: HTTP {(int)response.StatusCode}"); + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOpts, ct); + } + catch (Exception ex) + { + _logger.Error("IBKR", $"Contract-Info für conid {conid} fehlgeschlagen: {ex.Message}", ex); + return null; + } + } + + /// + /// Sucht Aktien-Contracts nach Symbolen. + /// GET /trsrv/stocks?symbols=AAPL,MSFT + /// + public async Task>?> SearchStocksBySymbolAsync( + string symbols, CancellationToken ct = default) + { + try + { + await RateLimitAsync(ct); + var response = await _http.GetAsync($"trsrv/stocks?symbols={Uri.EscapeDataString(symbols)}", ct); + + if (!response.IsSuccessStatusCode) + { + _logger.Warn("IBKR", $"Stock-Suche fehlgeschlagen: HTTP {(int)response.StatusCode}"); + return null; + } + + return await response.Content.ReadFromJsonAsync>>(JsonOpts, ct); + } + catch (Exception ex) + { + _logger.Error("IBKR", $"Stock-Suche fehlgeschlagen: {ex.Message}", ex); + return null; + } + } + + // ─── Historical Market Data ────────────────────────────────────────────── + + /// + /// Ruft historische Marktdaten ab. + /// GET /iserver/marketdata/history?conid={conid}&period={period}&bar={bar} + /// + /// period: 1d, 1w, 1m, 3m, 6m, 1y, 2y, 5y + /// bar: 1min, 5min, 15min, 30min, 1h, 1d, 1w, 1m + /// + public async Task GetHistoricalDataAsync( + long conid, string period = "2y", string bar = "1d", + bool outsideRth = false, CancellationToken ct = default) + { + try + { + await RateLimitAsync(ct); + var url = $"iserver/marketdata/history?conid={conid}" + + $"&period={Uri.EscapeDataString(period)}" + + $"&bar={Uri.EscapeDataString(bar)}" + + $"&outsideRth={outsideRth.ToString().ToLower()}"; + + var response = await _http.GetAsync(url, ct); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(ct); + _logger.Warn("IBKR", $"Historische Daten für conid {conid} fehlgeschlagen: HTTP {(int)response.StatusCode} – {errorBody}"); + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOpts, ct); + } + catch (Exception ex) + { + _logger.Error("IBKR", $"Historische Daten für conid {conid} fehlgeschlagen: {ex.Message}", ex); + return null; + } + } + + // ─── Disconnect ────────────────────────────────────────────────────────── + + public Task DisconnectAsync() + { + IsConnected = false; + return Task.CompletedTask; + } +} diff --git a/Core/IBKR/IBKRMarketDataRepository.cs b/Core/IBKR/IBKRMarketDataRepository.cs new file mode 100644 index 0000000..3c07883 --- /dev/null +++ b/Core/IBKR/IBKRMarketDataRepository.cs @@ -0,0 +1,152 @@ +using Dapper; +using IBKRTrader.Core.Database; +using IBKRTrader.Core.Logging; + +namespace IBKRTrader.Core.IBKR; + +/// +/// Datenzugriffsschicht für die core_ibkr_xxx-Tabellen. +/// Alle IBKR-Marktdaten-Operationen laufen über diese Klasse. +/// +public class IBKRMarketDataRepository +{ + private readonly DatabaseService _db; + private readonly LoggingService _logger; + + public IBKRMarketDataRepository(DatabaseService db, LoggingService logger) + { + _db = db; + _logger = logger; + } + + // ─── Instruments ───────────────────────────────────────────────────────── + + /// + /// Legt ein neues Instrument an oder aktualisiert ein bestehendes (UPSERT via conid). + /// Gibt die Instrument-ID zurück. + /// + public async Task UpsertInstrumentAsync(IBKRInstrument instr) + { + const string sql = @" + INSERT INTO `core_ibkr_instruments` + (`ibkr_conid`, `symbol`, `sec_type`, `exchange`, `primary_exchange`, + `currency`, `company_name`, `isin`, `sector`, `industry`, + `description`, `active`, `last_fetched`) + VALUES + (@IbkrConid, @Symbol, @SecType, @Exchange, @PrimaryExchange, + @Currency, @CompanyName, @Isin, @Sector, @Industry, + @Description, @Active, @LastFetched) + ON DUPLICATE KEY UPDATE + `symbol` = VALUES(`symbol`), + `sec_type` = VALUES(`sec_type`), + `exchange` = VALUES(`exchange`), + `primary_exchange` = VALUES(`primary_exchange`), + `currency` = VALUES(`currency`), + `company_name` = VALUES(`company_name`), + `isin` = VALUES(`isin`), + `sector` = VALUES(`sector`), + `industry` = VALUES(`industry`), + `description` = VALUES(`description`), + `last_fetched` = VALUES(`last_fetched`); + SELECT `id` FROM `core_ibkr_instruments` WHERE `ibkr_conid` = @IbkrConid;"; + + await using var conn = _db.CreateConnection(); + return await conn.ExecuteScalarAsync(sql, instr); + } + + /// Gibt alle aktiven Instrumente zurück. + public Task> GetAllActiveInstrumentsAsync() + => _db.QueryAsync( + "SELECT * FROM `core_ibkr_instruments` WHERE `active` = 1 ORDER BY `symbol`"); + + /// Findet ein Instrument anhand seiner IBKR ConID. + public Task GetInstrumentByConidAsync(long conid) + => _db.QueryFirstOrDefaultAsync( + "SELECT * FROM `core_ibkr_instruments` WHERE `ibkr_conid` = @conid", + new { conid }); + + /// Gibt die Anzahl aktiver Instrumente zurück. + public Task GetActiveInstrumentCountAsync() + => _db.ExecuteScalarAsync( + "SELECT COUNT(*) FROM `core_ibkr_instruments` WHERE `active` = 1"); + + // ─── Market Data ───────────────────────────────────────────────────────── + + /// + /// Fügt Marktdaten-Balken via UPSERT ein (ON DUPLICATE KEY UPDATE). + /// + public async Task UpsertMarketDataBatchAsync(IEnumerable bars) + { + const string sql = @" + INSERT INTO `core_ibkr_market_data` + (`instrument_id`, `bar_size`, `timestamp`, + `open`, `high`, `low`, `close`, `volume`, `wap`, `bar_count`) + VALUES + (@InstrumentId, @BarSize, @Timestamp, + @Open, @High, @Low, @Close, @Volume, @Wap, @BarCount) + ON DUPLICATE KEY UPDATE + `open` = VALUES(`open`), + `high` = VALUES(`high`), + `low` = VALUES(`low`), + `close` = VALUES(`close`), + `volume` = VALUES(`volume`), + `wap` = VALUES(`wap`), + `bar_count` = VALUES(`bar_count`)"; + + await using var conn = _db.CreateConnection(); + await conn.OpenAsync(); + await conn.ExecuteAsync(sql, bars); + } + + /// Gibt den neuesten Timestamp für ein Instrument zurück. + public Task GetLatestBarTimestampAsync(long instrumentId, string barSize = "daily") + => _db.QueryFirstOrDefaultAsync( + @"SELECT MAX(`timestamp`) FROM `core_ibkr_market_data` + WHERE `instrument_id` = @instrumentId AND `bar_size` = @barSize", + new { instrumentId, barSize }); + + /// Gibt die Anzahl Bars für ein Instrument zurück. + public Task GetBarCountAsync(long instrumentId, string barSize = "daily") + => _db.ExecuteScalarAsync( + @"SELECT COUNT(*) FROM `core_ibkr_market_data` + WHERE `instrument_id` = @instrumentId AND `bar_size` = @barSize", + new { instrumentId, barSize }); + + // ─── External Identifiers ──────────────────────────────────────────────── + + /// + /// Erstellt oder ignoriert ein External-Identifier-Mapping (IGNORE bei Duplikat). + /// + public Task UpsertExternalIdentifierAsync(long instrumentId, string source, string ticker) + => _db.ExecuteAsync(@" + INSERT IGNORE INTO `core_ibkr_external_identifiers` + (`instrument_id`, `source`, `ticker`) + VALUES (@instrumentId, @source, @ticker)", + new { instrumentId, source, ticker }); + + /// + /// Findet ein Instrument anhand eines externen Tickers (z.B. aus ct_trade). + /// + public Task FindInstrumentByExternalTickerAsync(string source, string ticker) + => _db.QueryFirstOrDefaultAsync(@" + SELECT i.* FROM `core_ibkr_instruments` i + INNER JOIN `core_ibkr_external_identifiers` e ON e.`instrument_id` = i.`id` + WHERE e.`source` = @source AND e.`ticker` = @ticker + LIMIT 1", + new { source, ticker }); + + /// + /// Findet alle einzigartigen Ticker aus ct_trade, die noch kein IBKR-Mapping haben. + /// + public Task> GetUnmappedTickersFromCongressTradesAsync() + => _db.QueryAsync(@" + SELECT DISTINCT t.`ticker` + FROM `ct_trade` t + WHERE t.`ticker` IS NOT NULL + AND t.`ticker` != '' + AND NOT EXISTS ( + SELECT 1 FROM `core_ibkr_external_identifiers` e + WHERE e.`source` = 'capitoltrades' AND e.`ticker` = t.`ticker` + ) + ORDER BY t.`ticker`"); +} diff --git a/Core/IBKR/IBKRModels.cs b/Core/IBKR/IBKRModels.cs new file mode 100644 index 0000000..3e35c53 --- /dev/null +++ b/Core/IBKR/IBKRModels.cs @@ -0,0 +1,336 @@ +using System.Text.Json.Serialization; + +namespace IBKRTrader.Core.IBKR; + +// ─── DB Entities ───────────────────────────────────────────────────────────── + +/// +/// Repräsentiert ein IBKR-Instrument in core_ibkr_instruments. +/// ibkr_conid ist der zentrale, eindeutige IBKR-Schlüssel. +/// +public class IBKRInstrument +{ + public long Id { get; set; } + public long IbkrConid { get; set; } + public string Symbol { get; set; } = ""; + public string SecType { get; set; } = "STK"; + public string Exchange { get; set; } = "SMART"; + public string? PrimaryExchange { get; set; } + public string Currency { get; set; } = "USD"; + public string? CompanyName { get; set; } + public string? Isin { get; set; } + public string? Sector { get; set; } + public string? Industry { get; set; } + public string? Description { get; set; } + public bool Active { get; set; } = true; + public DateTime? LastFetched { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + +/// +/// Repräsentiert einen OHLCV-Balken in core_ibkr_market_data. +/// +public class IBKRMarketBar +{ + public long InstrumentId { get; set; } + public string BarSize { get; set; } = "daily"; + public DateTime Timestamp { get; set; } + public decimal Open { get; set; } + public decimal High { get; set; } + public decimal Low { get; set; } + public decimal Close { get; set; } + public long Volume { get; set; } + public decimal? Wap { get; set; } + public int? BarCount { get; set; } +} + +/// +/// Externes Ticker-Mapping in core_ibkr_external_identifiers. +/// +public class IBKRExternalIdentifier +{ + public long Id { get; set; } + public long InstrumentId { get; set; } + public string Source { get; set; } = ""; + public string Ticker { get; set; } = ""; +} + +// ─── API Response DTOs ─────────────────────────────────────────────────────── + +/// +/// Response von GET /iserver/auth/status +/// +public class IBKRAuthStatus +{ + [JsonPropertyName("authenticated")] + public bool Authenticated { get; set; } + + [JsonPropertyName("competing")] + public bool Competing { get; set; } + + [JsonPropertyName("connected")] + public bool Connected { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("fail")] + public string? Fail { get; set; } +} + +/// +/// Einzelnes Ergebnis der Contract-Suche (POST /iserver/secdef/search). +/// +public class IBKRContractSearchResult +{ + [JsonPropertyName("conid")] + public long ConId { get; set; } + + [JsonPropertyName("companyHeader")] + public string? CompanyHeader { get; set; } + + [JsonPropertyName("companyName")] + public string? CompanyName { get; set; } + + [JsonPropertyName("symbol")] + public string? Symbol { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("restricted")] + public string? Restricted { get; set; } + + [JsonPropertyName("fop")] + public string? Fop { get; set; } + + [JsonPropertyName("opt")] + public string? Opt { get; set; } + + [JsonPropertyName("war")] + public string? War { get; set; } + + [JsonPropertyName("sections")] + public List? Sections { get; set; } +} + +/// +/// Sections innerhalb eines Contract-Suchergebnisses. +/// +public class IBKRContractSection +{ + [JsonPropertyName("secType")] + public string? SecType { get; set; } + + [JsonPropertyName("months")] + public string? Months { get; set; } + + [JsonPropertyName("symbol")] + public string? Symbol { get; set; } + + [JsonPropertyName("exchange")] + public string? Exchange { get; set; } + + [JsonPropertyName("legStr")] + public string? LegStr { get; set; } +} + +/// +/// Response von GET /iserver/contract/{conid}/info +/// +public class IBKRContractInfo +{ + [JsonPropertyName("cfi_code")] + public string? CfiCode { get; set; } + + [JsonPropertyName("symbol")] + public string? Symbol { get; set; } + + [JsonPropertyName("cusip")] + public string? Cusip { get; set; } + + [JsonPropertyName("expiry_full")] + public string? ExpiryFull { get; set; } + + [JsonPropertyName("con_id")] + public long ConId { get; set; } + + [JsonPropertyName("maturity_date")] + public string? MaturityDate { get; set; } + + [JsonPropertyName("industry")] + public string? Industry { get; set; } + + [JsonPropertyName("instrument_type")] + public string? InstrumentType { get; set; } + + [JsonPropertyName("trading_class")] + public string? TradingClass { get; set; } + + [JsonPropertyName("valid_exchanges")] + public string? ValidExchanges { get; set; } + + [JsonPropertyName("allow_sell_long")] + public bool? AllowSellLong { get; set; } + + [JsonPropertyName("is_zero_commission_security")] + public bool? IsZeroCommissionSecurity { get; set; } + + [JsonPropertyName("local_symbol")] + public string? LocalSymbol { get; set; } + + [JsonPropertyName("classifier")] + public string? Classifier { get; set; } + + [JsonPropertyName("currency")] + public string? Currency { get; set; } + + [JsonPropertyName("text")] + public string? Text { get; set; } + + [JsonPropertyName("underlying_con_id")] + public long? UnderlyingConId { get; set; } + + [JsonPropertyName("r_t_h")] + public bool? Rth { get; set; } + + [JsonPropertyName("company_name")] + public string? CompanyName { get; set; } + + [JsonPropertyName("smart_available")] + public bool? SmartAvailable { get; set; } + + [JsonPropertyName("exchange")] + public string? Exchange { get; set; } + + [JsonPropertyName("listing_exchange")] + public string? ListingExchange { get; set; } + + [JsonPropertyName("category")] + public string? Category { get; set; } + + [JsonPropertyName("sector")] + public string? Sector { get; set; } +} + +/// +/// Response von GET /iserver/marketdata/history +/// +public class IBKRHistoricalDataResponse +{ + [JsonPropertyName("symbol")] + public string? Symbol { get; set; } + + [JsonPropertyName("text")] + public string? Text { get; set; } + + [JsonPropertyName("priceFactor")] + public int? PriceFactor { get; set; } + + [JsonPropertyName("startTime")] + public string? StartTime { get; set; } + + [JsonPropertyName("high")] + public string? High { get; set; } + + [JsonPropertyName("low")] + public string? Low { get; set; } + + [JsonPropertyName("timePeriod")] + public string? TimePeriod { get; set; } + + [JsonPropertyName("barLength")] + public int? BarLength { get; set; } + + [JsonPropertyName("mdAvailability")] + public string? MdAvailability { get; set; } + + [JsonPropertyName("mktDataDelay")] + public int? MktDataDelay { get; set; } + + [JsonPropertyName("outsideRth")] + public bool? OutsideRth { get; set; } + + [JsonPropertyName("tradingDayDuration")] + public int? TradingDayDuration { get; set; } + + [JsonPropertyName("volumeFactor")] + public int? VolumeFactor { get; set; } + + [JsonPropertyName("priceDisplayRule")] + public int? PriceDisplayRule { get; set; } + + [JsonPropertyName("priceDisplayValue")] + public string? PriceDisplayValue { get; set; } + + [JsonPropertyName("negativeCapable")] + public bool? NegativeCapable { get; set; } + + [JsonPropertyName("messageVersion")] + public int? MessageVersion { get; set; } + + [JsonPropertyName("data")] + public List? Data { get; set; } + + [JsonPropertyName("points")] + public int? Points { get; set; } + + [JsonPropertyName("travelTime")] + public int? TravelTime { get; set; } +} + +/// +/// Einzelner OHLCV-Balken aus der IBKR Historical Data Response. +/// +public class IBKRHistoricalBar +{ + [JsonPropertyName("o")] + public decimal Open { get; set; } + + [JsonPropertyName("c")] + public decimal Close { get; set; } + + [JsonPropertyName("h")] + public decimal High { get; set; } + + [JsonPropertyName("l")] + public decimal Low { get; set; } + + [JsonPropertyName("v")] + public long Volume { get; set; } + + [JsonPropertyName("t")] + public long Timestamp { get; set; } // Unix timestamp in ms +} + +/// +/// Response von GET /trsrv/stocks?symbols=... +/// Die API gibt ein Dictionary zurück: { "AAPL": [{ ... }] } +/// +public class IBKRStockContract +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("chineseName")] + public string? ChineseName { get; set; } + + [JsonPropertyName("assetClass")] + public string? AssetClass { get; set; } + + [JsonPropertyName("contracts")] + public List? Contracts { get; set; } +} + +public class IBKRStockContractEntry +{ + [JsonPropertyName("conid")] + public long ConId { get; set; } + + [JsonPropertyName("exchange")] + public string? Exchange { get; set; } + + [JsonPropertyName("isUS")] + public bool? IsUS { get; set; } +} diff --git a/Core/Logging/AppLogLevel.cs b/Core/Logging/AppLogLevel.cs new file mode 100644 index 0000000..167bc71 --- /dev/null +++ b/Core/Logging/AppLogLevel.cs @@ -0,0 +1,8 @@ +namespace IBKRTrader.Core.Logging; + +public enum AppLogLevel +{ + Info = 0, + Warn = 1, + Error = 2 +} diff --git a/Core/Logging/LogEntry.cs b/Core/Logging/LogEntry.cs new file mode 100644 index 0000000..db75338 --- /dev/null +++ b/Core/Logging/LogEntry.cs @@ -0,0 +1,10 @@ +namespace IBKRTrader.Core.Logging; + +/// Immutable log entry – wird in Datei und RichTextBox geschrieben. +public sealed record LogEntry( + DateTime Timestamp, + AppLogLevel Level, + string Module, + string Message, + Exception? Exception = null +); diff --git a/Core/Logging/LoggingService.cs b/Core/Logging/LoggingService.cs new file mode 100644 index 0000000..11aab33 --- /dev/null +++ b/Core/Logging/LoggingService.cs @@ -0,0 +1,105 @@ +using System.Runtime.CompilerServices; + +namespace IBKRTrader.Core.Logging; + +/// +/// Thread-sicherer Logging-Service. +/// – Schreibt farbig in die RichTextBox (UI-Thread-safe via BeginInvoke) +/// – Schreibt in Logs\{Module}\{Level}-dd-MM-yy.txt +/// +public class LoggingService +{ + private RichTextBox? _rtb; + private AppLogLevel _minLevel = AppLogLevel.Info; + private readonly object _fileLock = new(); + + private static readonly string LogBaseDir = + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); + + // ─── Konfiguration ──────────────────────────────────────────────────────── + + public void AttachRichTextBox(RichTextBox rtb) => _rtb = rtb; + + public void SetMinLevel(AppLogLevel level) => _minLevel = level; + + // ─── Öffentliche API ───────────────────────────────────────────────────── + + public void Info (string module, string message, Exception? ex = null) + => Write(AppLogLevel.Info, module, message, ex); + + public void Warn (string module, string message, Exception? ex = null) + => Write(AppLogLevel.Warn, module, message, ex); + + public void Error(string module, string message, Exception? ex = null) + => Write(AppLogLevel.Error, module, message, ex); + + public void Write(AppLogLevel level, string module, string message, Exception? ex = null) + { + if (level < _minLevel) return; + var entry = new LogEntry(DateTime.Now, level, module, message, ex); + WriteToFile(entry); + WriteToRtb(entry); + } + + // ─── Datei ──────────────────────────────────────────────────────────────── + + private void WriteToFile(LogEntry e) + { + try + { + var dir = Path.Combine(LogBaseDir, e.Module); + Directory.CreateDirectory(dir); + + var file = Path.Combine(dir, $"{e.Level}-{e.Timestamp:dd-MM-yy}.txt"); + var line = $"[{e.Timestamp:HH:mm:ss}] {e.Message}"; + if (e.Exception != null) + line += $"\r\n {e.Exception}"; + + lock (_fileLock) + File.AppendAllText(file, line + "\r\n"); + } + catch { /* Logging darf niemals abstürzen */ } + } + + // ─── RichTextBox ────────────────────────────────────────────────────────── + + private static readonly Color ColorInfo = Color.FromArgb(150, 210, 150); + private static readonly Color ColorWarn = Color.FromArgb(255, 190, 60); + private static readonly Color ColorError = Color.FromArgb(255, 80, 80); + + private void WriteToRtb(LogEntry e) + { + if (_rtb == null) return; + try + { + var color = e.Level switch + { + AppLogLevel.Warn => ColorWarn, + AppLogLevel.Error => ColorError, + _ => ColorInfo + }; + var text = $"[{e.Timestamp:HH:mm:ss}] [{e.Level.ToString().ToUpper(),-5}] [{e.Module}] {e.Message}"; + if (e.Exception != null) + text += $"\r\n {e.Exception.Message}"; + text += "\r\n"; + + if (_rtb.InvokeRequired) + _rtb.BeginInvoke(() => AppendColored(text, color)); + else + AppendColored(text, color); + } + catch { } + } + + private void AppendColored(string text, Color color) + { + if (_rtb == null) return; + _rtb.SelectionStart = _rtb.TextLength; + _rtb.SelectionLength = 0; + _rtb.SelectionColor = color; + _rtb.AppendText(text); + _rtb.SelectionColor = _rtb.ForeColor; + if (_rtb.TextLength > 0) + _rtb.ScrollToCaret(); + } +} diff --git a/Core/Settings/AppSettings.cs b/Core/Settings/AppSettings.cs new file mode 100644 index 0000000..d2b9fe3 --- /dev/null +++ b/Core/Settings/AppSettings.cs @@ -0,0 +1,207 @@ +using System.ComponentModel; + +namespace IBKRTrader.Core.Settings; + +// ─── Database ──────────────────────────────────────────────────────────────── + +[TypeConverter(typeof(ExpandableObjectConverter))] +public class DatabaseSettings +{ + [Category("Database")] + [DisplayName("Host")] + [Description("MySQL-Serveradresse (Hostname oder IP)")] + public string Host { get; set; } = "larx.your-database.de"; + + [Category("Database")] + [DisplayName("Port")] + [Description("MySQL-Port (Standard: 3306)")] + public int Port { get; set; } = 3306; + + [Category("Database")] + [DisplayName("Database")] + [Description("Name der MySQL-Datenbank")] + public string Database { get; set; } = "bergisnu_db0"; + + [Category("Database")] + [DisplayName("User")] + [Description("MySQL-Benutzername")] + public string User { get; set; } = "bergisnu_0"; + + [Category("Database")] + [DisplayName("Password")] + [Description("MySQL-Passwort")] + [PasswordPropertyText(true)] + public string Password { get; set; } = "o5/A49+f}:Da"; + + public string BuildConnectionString() => + $"Server={Host};Port={Port};Database={Database};" + + $"User={User};Password={Password};" + + $"AllowPublicKeyRetrieval=true;SslMode=Preferred;CharSet=utf8mb4;" + + $"ConnectionTimeout=15;DefaultCommandTimeout=30;"; + + public override string ToString() => $"{User}@{Host}:{Port}/{Database}"; +} + +// ─── IBKR TWS/Gateway (Socket API) ─────────────────────────────────────────── + +[TypeConverter(typeof(ExpandableObjectConverter))] +public class IBKRSettings +{ + [Category("IBKR Gateway")] + [DisplayName("Host")] + [Description("Adresse des IBKR Gateway (Standard: 127.0.0.1)")] + public string Host { get; set; } = "127.0.0.1"; + + [Category("IBKR Gateway")] + [DisplayName("Port")] + [Description("IBKR Gateway Port (Paper: 4002, Live: 4001)")] + public int Port { get; set; } = 4001; + + [Category("IBKR Gateway")] + [DisplayName("Client ID")] + [Description("Eindeutige Client-ID für die API-Verbindung")] + public int ClientId { get; set; } = 1; + + public override string ToString() => $"{Host}:{Port} (Client {ClientId})"; +} + +// ─── IBKR Web API (Client Portal Gateway) ──────────────────────────────────── + +[TypeConverter(typeof(ExpandableObjectConverter))] +public class IBKRWebApiSettings +{ + [Category("IBKR Web API")] + [DisplayName("Aktiviert")] + [Description("Aktiviert die IBKR Web API Marktdaten-Worker")] + public bool Enabled { get; set; } = false; + + [Category("IBKR Web API")] + [DisplayName("Gateway URL")] + [Description("URL des Client Portal Gateway (Standard: https://localhost:5000)")] + public string GatewayUrl { get; set; } = "https://localhost:5000"; + + [Category("IBKR Web API")] + [DisplayName("History-Zeitraum")] + [Description("Zeitraum für den ersten historischen Import (z.B. 2Y, 5Y, 1Y)")] + public string HistoryPeriod { get; set; } = "2Y"; + + [Category("IBKR Web API")] + [DisplayName("Max Requests/Sek")] + [Description("Maximale API-Anfragen pro Sekunde (IBKR-Limit: 10, empfohlen: 5)")] + public int MaxRequestsPerSecond { get; set; } = 5; + + public override string ToString() => Enabled ? $"Aktiv – {GatewayUrl}" : "Deaktiviert"; +} + +// ─── Logging ───────────────────────────────────────────────────────────────── + +[TypeConverter(typeof(ExpandableObjectConverter))] +public class LoggingSettings +{ + [Category("Logging")] + [DisplayName("Log Level")] + [Description("Minimales Log-Level: Info, Warn oder Error")] + public string Level { get; set; } = "Info"; + + [Category("Logging")] + [DisplayName("Max. Dateigröße (MB)")] + [Description("Maximale Größe einer Log-Datei in Megabyte")] + public int MaxFileSizeMb { get; set; } = 10; + + [Category("Logging")] + [DisplayName("Aufbewahrung (Tage)")] + [Description("Anzahl Tage, nach denen Log-Dateien gelöscht werden")] + public int RetentionDays { get; set; } = 30; + + public override string ToString() => $"Level: {Level}"; +} + +// ─── Webserver ─────────────────────────────────────────────────────────────── + +[TypeConverter(typeof(ExpandableObjectConverter))] +public class WebserverSettings +{ + [Category("Webserver")] + [DisplayName("Port")] + [Description("Port des internen Webservers (Web-UI)")] + public int Port { get; set; } = 5001; + + [Category("Webserver")] + [DisplayName("Aktiviert")] + [Description("Webserver und REST-API beim Start aktivieren")] + public bool Enabled { get; set; } = false; + + public override string ToString() => Enabled ? $"Aktiv auf Port {Port}" : "Deaktiviert"; +} + +// ─── Workers ───────────────────────────────────────────────────────────────── + +[TypeConverter(typeof(ExpandableObjectConverter))] +public class WorkerSchedule +{ + [Category("Worker")] + [DisplayName("Aktiviert")] + public bool Enabled { get; set; } = true; + + [Category("Worker")] + [DisplayName("Interval (Minuten)")] + public int IntervalMinutes { get; set; } = 30; + + public override string ToString() => Enabled ? $"alle {IntervalMinutes} min" : "Deaktiviert"; +} + +[TypeConverter(typeof(ExpandableObjectConverter))] +public class WorkerSettings +{ + [Category("Workers")] + [DisplayName("BackupWorker")] + [Description("Zeitplan für den automatischen Backup-Worker")] + public WorkerSchedule BackupWorker { get; set; } = new(); + + [Category("Workers")] + [DisplayName("IBKR Instrument-Sync")] + [Description("Zeitplan für die IBKR Stammdaten-Synchronisation (alle 30 Tage)")] + public WorkerSchedule InstrumentSyncWorker { get; set; } = new() { IntervalMinutes = 43200 }; // 30 Tage + + [Category("Workers")] + [DisplayName("IBKR Preishistorie")] + [Description("Zeitplan für den täglichen IBKR Preishistorie-Abruf")] + public WorkerSchedule PriceHistoryWorker { get; set; } = new() { IntervalMinutes = 1440 }; // 24h + + public override string ToString() => "Worker-Konfiguration"; +} + +// ─── Root ──────────────────────────────────────────────────────────────────── + +public class AppSettings +{ + [Category("Database")] + [DisplayName("Datenbank")] + [Description("MySQL-Verbindungseinstellungen")] + public DatabaseSettings Database { get; set; } = new(); + + [Category("IBKR Gateway")] + [DisplayName("IBKR")] + [Description("Interactive Brokers Gateway-Verbindung (Socket API)")] + public IBKRSettings IBKR { get; set; } = new(); + + [Category("IBKR Web API")] + [DisplayName("IBKR Web API")] + [Description("Client Portal Gateway für Marktdaten")] + public IBKRWebApiSettings IBKRWebApi { get; set; } = new(); + + [Category("Logging")] + [DisplayName("Logging")] + [Description("Log-Level und Datei-Einstellungen")] + public LoggingSettings Logging { get; set; } = new(); + + [Category("Webserver")] + [DisplayName("Webserver")] + [Description("Interner HTTP-Server und REST-API")] + public WebserverSettings Webserver { get; set; } = new(); + + [Category("Workers")] + [DisplayName("Worker-Einstellungen")] + [Description("Zeitpläne der einzelnen Core-Worker")] + public WorkerSettings WorkerSettings { get; set; } = new(); +} diff --git a/Core/Settings/SettingsService.cs b/Core/Settings/SettingsService.cs new file mode 100644 index 0000000..c789a41 --- /dev/null +++ b/Core/Settings/SettingsService.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IBKRTrader.Core.Settings; + +/// +/// Lädt und speichert settings.json neben der EXE. +/// Singleton – wird beim App-Start einmalig geladen. +/// +public class SettingsService +{ + private static readonly string SettingsPath = + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "settings.json"); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + PropertyNameCaseInsensitive = true + }; + + public AppSettings Settings { get; private set; } = new(); + + public void Load() + { + if (!File.Exists(SettingsPath)) + { + Save(); // Defaults auf Disk schreiben + return; + } + + try + { + var json = File.ReadAllText(SettingsPath); + Settings = JsonSerializer.Deserialize(json, JsonOptions) ?? new AppSettings(); + } + catch + { + Settings = new AppSettings(); + Save(); + } + } + + public void Save() + { + var json = JsonSerializer.Serialize(Settings, JsonOptions); + File.WriteAllText(SettingsPath, json); + } +} diff --git a/Core/Trading/TradeHistoryService.cs b/Core/Trading/TradeHistoryService.cs new file mode 100644 index 0000000..1043112 --- /dev/null +++ b/Core/Trading/TradeHistoryService.cs @@ -0,0 +1,47 @@ +using IBKRTrader.Core.Database; +using IBKRTrader.Core.Logging; + +namespace IBKRTrader.Core.Trading; + +/// Verwaltet core_trade_history – Lesen und Schreiben von Trade-Einträgen. +public class TradeHistoryService +{ + private readonly DatabaseService _db; + private readonly LoggingService _logger; + + public TradeHistoryService(DatabaseService db, LoggingService logger) + { + _db = db; + _logger = logger; + } + + public async Task RecordTradeAsync( + string module, string symbol, string action, + decimal quantity, decimal price, string? ibkrOrderId = null, string? notes = null) + { + await _db.ExecuteAsync(@" + INSERT INTO `core_trade_history` + (module, symbol, action, quantity, price, total_value, traded_at, ibkr_order_id, status, notes) + VALUES + (@module, @symbol, @action, @qty, @price, @total, @now, @orderId, 'Executed', @notes)", + new + { + module, + symbol, + action, + qty = quantity, + price, + total = quantity * price, + now = DateTime.UtcNow, + orderId = ibkrOrderId, + notes + }); + + _logger.Info(module, $"Trade gespeichert: {action} {quantity}x {symbol} @ {price:F2}"); + } + + public async Task> GetRecentTradesAsync(int limit = 100) + => await _db.QueryAsync( + "SELECT * FROM `core_trade_history` ORDER BY traded_at DESC LIMIT @limit", + new { limit }); +} diff --git a/Core/Workers/BuiltIn/BackupWorker.cs b/Core/Workers/BuiltIn/BackupWorker.cs new file mode 100644 index 0000000..7b9e436 --- /dev/null +++ b/Core/Workers/BuiltIn/BackupWorker.cs @@ -0,0 +1,146 @@ +using IBKRTrader.Core.Database; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Settings; + +namespace IBKRTrader.Core.Workers.BuiltIn; + +/// +/// 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. +/// +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))); + } +} diff --git a/Core/Workers/BuiltIn/IBKRInstrumentSyncWorker.cs b/Core/Workers/BuiltIn/IBKRInstrumentSyncWorker.cs new file mode 100644 index 0000000..9362870 --- /dev/null +++ b/Core/Workers/BuiltIn/IBKRInstrumentSyncWorker.cs @@ -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; + +/// +/// Synchronisiert IBKR-Instrumentenstammdaten alle 30 Tage. +/// +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."); + } +} diff --git a/Core/Workers/BuiltIn/IBKRPriceHistoryWorker.cs b/Core/Workers/BuiltIn/IBKRPriceHistoryWorker.cs new file mode 100644 index 0000000..f79f46c --- /dev/null +++ b/Core/Workers/BuiltIn/IBKRPriceHistoryWorker.cs @@ -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; + +/// +/// 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 +/// +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."); + } + + /// + /// Bestimmt den HMDS-Period-Parameter basierend auf dem letzten vorhandenen Bar. + /// + 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"; + } + + /// + /// Konvertiert IBKR API-Bars in DB-Entities. + /// + private static List ConvertBars( + long instrumentId, IBKRHistoricalDataResponse response) + { + var bars = new List(); + + 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; + } +} diff --git a/Core/Workers/BuiltIn/WebApiService.cs b/Core/Workers/BuiltIn/WebApiService.cs new file mode 100644 index 0000000..9e1011c --- /dev/null +++ b/Core/Workers/BuiltIn/WebApiService.cs @@ -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; + +/// +/// 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. +/// +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"; + } + + /// Setzt die WorkerEngine nach DI-Aufbau (zirkulare Abhängigkeit vermeiden). + 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 { } + } +} diff --git a/Core/Workers/BuiltIn/WebserverService.cs b/Core/Workers/BuiltIn/WebserverService.cs new file mode 100644 index 0000000..7595b08 --- /dev/null +++ b/Core/Workers/BuiltIn/WebserverService.cs @@ -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; + +/// +/// 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. +/// +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 */ } + } +} diff --git a/Core/Workers/IWorker.cs b/Core/Workers/IWorker.cs new file mode 100644 index 0000000..680c24d --- /dev/null +++ b/Core/Workers/IWorker.cs @@ -0,0 +1,29 @@ +namespace IBKRTrader.Core.Workers; + +/// +/// Vertrag für jeden Worker oder Service im System. +/// Core und Module müssen dieses Interface implementieren. +/// +public interface IWorker +{ + /// Anzeigename in dgv_workerlist. + string Name { get; } + + /// Modul-Kürzel (z. B. "Core", "CT"). + string Module { get; } + + /// Worker = periodisch / Service = dauerhaft. + WorkerType Type { get; } + + /// 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/Core/Workers/WorkerBase.cs b/Core/Workers/WorkerBase.cs new file mode 100644 index 0000000..85d83bd --- /dev/null +++ b/Core/Workers/WorkerBase.cs @@ -0,0 +1,162 @@ +using IBKRTrader.Core.Database; +using IBKRTrader.Core.Logging; + +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 +/// +public abstract class WorkerBase : IWorker +{ + // ─── Abstrakte Member ───────────────────────────────────────────────────── + + public abstract string Name { get; } + public abstract string Module { get; } + public virtual WorkerType Type => WorkerType.Worker; + + /// Intervall zwischen zwei Runs. null = Service (keine Wiederholung). + protected abstract TimeSpan? Interval { get; } + + /// Führt die eigentliche Arbeit des Workers aus. + 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"; + } +} diff --git a/Core/Workers/WorkerEngine.cs b/Core/Workers/WorkerEngine.cs new file mode 100644 index 0000000..abd31e9 --- /dev/null +++ b/Core/Workers/WorkerEngine.cs @@ -0,0 +1,96 @@ +using System.Collections.Concurrent; +using System.ComponentModel; +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 +/// +public class WorkerEngine +{ + private readonly IEnumerable _workers; + private readonly LoggingService _logger; + private readonly ConcurrentDictionary _registry = new(); + private CancellationTokenSource _cts = new(); + + /// Live-bindbare Liste für dgv_workerlist. + 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) + { + 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); + } + } +} diff --git a/Core/Workers/WorkerInfo.cs b/Core/Workers/WorkerInfo.cs new file mode 100644 index 0000000..1c27966 --- /dev/null +++ b/Core/Workers/WorkerInfo.cs @@ -0,0 +1,58 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace IBKRTrader.Core.Workers; + +/// +/// ViewModel-Objekt für eine Zeile in dgv_workerlist. +/// Implementiert INotifyPropertyChanged für automatisches DataGridView-Binding. +/// +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); } + + /// Interner Status – wird nicht direkt als DGV-Spalte verwendet, + /// aber steuert die Info-Spalte. + 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(ref T field, T value, [CallerMemberName] string? prop = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop)); + return true; + } +} diff --git a/Core/Workers/WorkerStatus.cs b/Core/Workers/WorkerStatus.cs new file mode 100644 index 0000000..af7691a --- /dev/null +++ b/Core/Workers/WorkerStatus.cs @@ -0,0 +1,10 @@ +namespace IBKRTrader.Core.Workers; + +public enum WorkerStatus +{ + Idle, + Running, + Error, + Stopped, + Disabled +} diff --git a/Core/Workers/WorkerType.cs b/Core/Workers/WorkerType.cs new file mode 100644 index 0000000..32a0984 --- /dev/null +++ b/Core/Workers/WorkerType.cs @@ -0,0 +1,10 @@ +namespace IBKRTrader.Core.Workers; + +public enum WorkerType +{ + /// Läuft periodisch nach einem festen Zeitplan. + Worker, + + /// Läuft permanent (z. B. Webserver, WebAPI). + Service +} diff --git a/Form1.Designer.cs b/Form1.Designer.cs new file mode 100644 index 0000000..ae32242 --- /dev/null +++ b/Form1.Designer.cs @@ -0,0 +1,260 @@ +namespace IBKRTrader +{ + partial class Form1 + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1)); + menuStrip1 = new MenuStrip(); + toolStrip1 = new ToolStrip(); + statusStrip1 = new StatusStrip(); + tabControl1 = new TabControl(); + tabPage_dash = new TabPage(); + tabPage_sett = new TabPage(); + tabPage_mod_congresstrade = new TabPage(); + btn_activateTrading = new ToolStripButton(); + tabPage_history = new TabPage(); + tabPage_workers = new TabPage(); + toolStrip2 = new ToolStrip(); + dgv_workerlist = new DataGridView(); + pg_settings = new PropertyGrid(); + tabPage_logs = new TabPage(); + rtb_logs = new RichTextBox(); + toolStrip3 = new ToolStrip(); + toolStrip1.SuspendLayout(); + tabControl1.SuspendLayout(); + tabPage_sett.SuspendLayout(); + tabPage_workers.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)dgv_workerlist).BeginInit(); + tabPage_logs.SuspendLayout(); + SuspendLayout(); + // + // menuStrip1 + // + menuStrip1.ImageScalingSize = new Size(24, 24); + menuStrip1.Location = new Point(0, 0); + menuStrip1.Name = "menuStrip1"; + menuStrip1.Size = new Size(2441, 24); + menuStrip1.TabIndex = 0; + menuStrip1.Text = "menuStrip1"; + // + // toolStrip1 + // + toolStrip1.ImageScalingSize = new Size(24, 24); + toolStrip1.Items.AddRange(new ToolStripItem[] { btn_activateTrading }); + toolStrip1.Location = new Point(0, 24); + toolStrip1.Name = "toolStrip1"; + toolStrip1.Size = new Size(2441, 34); + toolStrip1.TabIndex = 1; + toolStrip1.Text = "toolStrip1"; + // + // statusStrip1 + // + statusStrip1.ImageScalingSize = new Size(24, 24); + statusStrip1.Location = new Point(0, 1197); + statusStrip1.Name = "statusStrip1"; + statusStrip1.Size = new Size(2441, 22); + statusStrip1.TabIndex = 2; + statusStrip1.Text = "statusStrip1"; + // + // tabControl1 + // + tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; + tabControl1.Controls.Add(tabPage_dash); + tabControl1.Controls.Add(tabPage_history); + tabControl1.Controls.Add(tabPage_sett); + tabControl1.Controls.Add(tabPage_logs); + tabControl1.Controls.Add(tabPage_workers); + tabControl1.Controls.Add(tabPage_mod_congresstrade); + tabControl1.Location = new Point(12, 52); + tabControl1.Name = "tabControl1"; + tabControl1.SelectedIndex = 0; + tabControl1.Size = new Size(2429, 1142); + tabControl1.TabIndex = 3; + // + // tabPage_dash + // + tabPage_dash.Location = new Point(4, 34); + tabPage_dash.Name = "tabPage_dash"; + tabPage_dash.Padding = new Padding(3); + tabPage_dash.Size = new Size(2421, 1104); + tabPage_dash.TabIndex = 0; + tabPage_dash.Text = "Dashboard"; + tabPage_dash.UseVisualStyleBackColor = true; + // + // tabPage_sett + // + tabPage_sett.Controls.Add(pg_settings); + tabPage_sett.Location = new Point(4, 34); + tabPage_sett.Name = "tabPage_sett"; + tabPage_sett.Padding = new Padding(3); + tabPage_sett.Size = new Size(2421, 1104); + tabPage_sett.TabIndex = 1; + tabPage_sett.Text = "Settings"; + tabPage_sett.UseVisualStyleBackColor = true; + // + // tabPage_mod_congresstrade + // + tabPage_mod_congresstrade.Location = new Point(4, 34); + tabPage_mod_congresstrade.Name = "tabPage_mod_congresstrade"; + tabPage_mod_congresstrade.Size = new Size(2421, 1104); + tabPage_mod_congresstrade.TabIndex = 2; + tabPage_mod_congresstrade.Text = "Module: CongressTrading"; + tabPage_mod_congresstrade.UseVisualStyleBackColor = true; + // + // btn_activateTrading + // + btn_activateTrading.Alignment = ToolStripItemAlignment.Right; + btn_activateTrading.Image = (Image)resources.GetObject("btn_activateTrading.Image"); + btn_activateTrading.ImageTransparentColor = Color.Magenta; + btn_activateTrading.Name = "btn_activateTrading"; + btn_activateTrading.Size = new Size(166, 29); + btn_activateTrading.Text = "Activate Trading"; + // + // tabPage_history + // + tabPage_history.Location = new Point(4, 34); + tabPage_history.Name = "tabPage_history"; + tabPage_history.Padding = new Padding(3); + tabPage_history.Size = new Size(2421, 1104); + tabPage_history.TabIndex = 3; + tabPage_history.Text = "Trade History"; + tabPage_history.UseVisualStyleBackColor = true; + // + // tabPage_workers + // + tabPage_workers.Controls.Add(dgv_workerlist); + tabPage_workers.Controls.Add(toolStrip2); + tabPage_workers.Location = new Point(4, 34); + tabPage_workers.Name = "tabPage_workers"; + tabPage_workers.Padding = new Padding(3); + tabPage_workers.Size = new Size(2421, 1104); + tabPage_workers.TabIndex = 4; + tabPage_workers.Text = "Workers / Services"; + tabPage_workers.UseVisualStyleBackColor = true; + // + // toolStrip2 + // + toolStrip2.ImageScalingSize = new Size(24, 24); + toolStrip2.Location = new Point(3, 3); + toolStrip2.Name = "toolStrip2"; + toolStrip2.Size = new Size(2415, 25); + toolStrip2.TabIndex = 0; + toolStrip2.Text = "toolStrip2"; + // + // dgv_workerlist + // + dgv_workerlist.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; + dgv_workerlist.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize; + dgv_workerlist.Location = new Point(6, 31); + dgv_workerlist.Name = "dgv_workerlist"; + dgv_workerlist.RowHeadersWidth = 62; + dgv_workerlist.Size = new Size(2409, 1067); + dgv_workerlist.TabIndex = 1; + // + // pg_settings + // + pg_settings.BackColor = SystemColors.Control; + pg_settings.Location = new Point(3, 6); + pg_settings.Name = "pg_settings"; + pg_settings.Size = new Size(1101, 1075); + pg_settings.TabIndex = 0; + // + // tabPage_logs + // + tabPage_logs.Controls.Add(toolStrip3); + tabPage_logs.Controls.Add(rtb_logs); + tabPage_logs.Location = new Point(4, 34); + tabPage_logs.Name = "tabPage_logs"; + tabPage_logs.Padding = new Padding(3); + tabPage_logs.Size = new Size(2421, 1104); + tabPage_logs.TabIndex = 5; + tabPage_logs.Text = "Logs"; + tabPage_logs.UseVisualStyleBackColor = true; + // + // rtb_logs + // + rtb_logs.Location = new Point(6, 31); + rtb_logs.Name = "rtb_logs"; + rtb_logs.Size = new Size(1849, 811); + rtb_logs.TabIndex = 0; + rtb_logs.Text = ""; + // + // toolStrip3 + // + toolStrip3.ImageScalingSize = new Size(24, 24); + toolStrip3.Location = new Point(3, 3); + toolStrip3.Name = "toolStrip3"; + toolStrip3.Size = new Size(2415, 25); + toolStrip3.TabIndex = 1; + toolStrip3.Text = "toolStrip3"; + // + // Form1 + // + AutoScaleDimensions = new SizeF(10F, 25F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(2441, 1219); + Controls.Add(tabControl1); + Controls.Add(statusStrip1); + Controls.Add(toolStrip1); + Controls.Add(menuStrip1); + MainMenuStrip = menuStrip1; + Name = "Form1"; + Text = "IBKRTrader"; + toolStrip1.ResumeLayout(false); + toolStrip1.PerformLayout(); + tabControl1.ResumeLayout(false); + tabPage_sett.ResumeLayout(false); + tabPage_workers.ResumeLayout(false); + tabPage_workers.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)dgv_workerlist).EndInit(); + tabPage_logs.ResumeLayout(false); + tabPage_logs.PerformLayout(); + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private MenuStrip menuStrip1; + private ToolStrip toolStrip1; + private ToolStripButton btn_activateTrading; + private StatusStrip statusStrip1; + private TabControl tabControl1; + private TabPage tabPage_dash; + private TabPage tabPage_sett; + private TabPage tabPage_mod_congresstrade; + private TabPage tabPage_history; + private TabPage tabPage_workers; + private DataGridView dgv_workerlist; + private ToolStrip toolStrip2; + private PropertyGrid pg_settings; + private TabPage tabPage_logs; + private RichTextBox rtb_logs; + private ToolStrip toolStrip3; + } +} diff --git a/Form1.cs b/Form1.cs new file mode 100644 index 0000000..0466139 --- /dev/null +++ b/Form1.cs @@ -0,0 +1,131 @@ +using IBKRTrader.Core.Database.Migrations; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Settings; +using IBKRTrader.Core.Workers; +using IBKRTrader.Modules.CongressTrading; +using IBKRTrader.UI; + +namespace IBKRTrader; + +public partial class Form1 : Form +{ + private readonly LoggingService _logger; + private readonly WorkerEngine _workerEngine; + private readonly SettingsService _settings; + private readonly CoreMigrations _migrations; + private readonly IBKRMigrations _ibkrMigrations; + private readonly IServiceProvider _services; + + private LogPanelController? _logPanel; + + public Form1( + LoggingService logger, + WorkerEngine workerEngine, + SettingsService settings, + CoreMigrations migrations, + IBKRMigrations ibkrMigrations, + IServiceProvider services) + { + InitializeComponent(); + + _logger = logger; + _workerEngine = workerEngine; + _settings = settings; + _migrations = migrations; + _ibkrMigrations = ibkrMigrations; + _services = services; + } + + // ─── Form-Events ────────────────────────────────────────────────────────── + + protected override void OnLoad(EventArgs e) + { + base.OnLoad(e); + InitializeLogPanel(); + InitializeWorkerList(); + InitializeSettingsGrid(); + _ = StartupAsync(); + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + base.OnFormClosing(e); + // Worker sauber beenden (feuert & vergisst – Form muss kurz warten) + _workerEngine.StopAllAsync().GetAwaiter().GetResult(); + } + + // ─── Initialisierung ────────────────────────────────────────────────────── + + private void InitializeLogPanel() + { + // RichTextBox auf Dock.Fill setzen + rtb_logs.Dock = DockStyle.Fill; + _logPanel = new LogPanelController(rtb_logs, _logger); + + // LogLevel aus Settings setzen + var levelStr = _settings.Settings.Logging.Level; + if (Enum.TryParse(levelStr, true, out var level)) + _logger.SetMinLevel(level); + } + + private void InitializeWorkerList() + { + WorkerListBindingSource.Setup(dgv_workerlist, _workerEngine.WorkerInfos); + } + + private void InitializeSettingsGrid() + { + pg_settings.SelectedObject = _settings.Settings; + } + + // ─── Async Startup ──────────────────────────────────────────────────────── + + private async Task StartupAsync() + { + _logger.Info("Core", "=== IBKRTrader startet ==="); + _logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}"); + + // DB-Verbindung testen und Core-Migrationen laufen lassen + try + { + _logger.Info("Core", "Verbinde mit Datenbank..."); + await _migrations.RunAsync(); + await _ibkrMigrations.RunAsync(); + } + catch (Exception ex) + { + _logger.Error("Core", "Core-Datenbankfehler beim Start.", ex); + } + + // Modul-Migrationen + try + { + await CongressTradingModule.InitializeAsync(_services); + } + catch (Exception ex) + { + _logger.Error("CT", "CongressTrading-Migrationen fehlgeschlagen.", ex); + } + + // Worker starten + await _workerEngine.StartAllAsync(); + + _logger.Info("Core", "IBKRTrader bereit."); + UpdateStatusBar("Bereit"); + } + + // ─── StatusBar ──────────────────────────────────────────────────────────── + + private void UpdateStatusBar(string text) + { + if (statusStrip1.InvokeRequired) + statusStrip1.BeginInvoke(() => UpdateStatusBar(text)); + else + { + // Vorhandenes Label nutzen oder neues anlegen + if (statusStrip1.Items.Count == 0) + statusStrip1.Items.Add(new ToolStripStatusLabel()); + statusStrip1.Items[0].Text = $"Status: {text} | {DateTime.Now:HH:mm:ss}"; + } + } +} diff --git a/Form1.resx b/Form1.resx new file mode 100644 index 0000000..d136883 --- /dev/null +++ b/Form1.resx @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 17, 17 + + + 175, 17 + + + 320, 17 + + + 625, 17 + + + 480, 17 + + + + + iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8 + YQUAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAEBSURBVEhL3ZIvC8JAGIf3OTTZ/A62+RXs2g2rFsGi2P0C + WlwT02wGMQjLhgVRnAMR/5bVV37CHeduf9TdFQfPDt7wPOy9GWEYkk4MvGzbJsuylAInD2BgmqZS4JQC + 99tDCcoCl+OBPKdH3qxHe3eiPuAOTFq0CxwWURYQ5QBfojfgKA5sl0Mux7pwJx8H/NOdmvPj64yKs8gM + QFqdBlQc7V/nt5HUwDq4cTnj20hiYLW7Unnsv8l/icQGKrVGojwrgpk4lwL1VpdKw40kjCMaYfclzqVA + 3z1LojSYTPwZ2Bxrzh1gsujPALBmrDt3IA2sG2vXFgBw/lFAJwh0dGLofp4xWrfOyeSKuAAAAABJRU5E + rkJggg== + + + + 625, 17 + + \ No newline at end of file diff --git a/IBKRTrader.csproj b/IBKRTrader.csproj new file mode 100644 index 0000000..8ae8967 --- /dev/null +++ b/IBKRTrader.csproj @@ -0,0 +1,28 @@ + + + + WinExe + net10.0-windows + enable + true + enable + + en + PerMonitorV2 + + + + + + + + + + + + + PreserveNewest + + + + \ No newline at end of file diff --git a/IBKRTrader.slnx b/IBKRTrader.slnx new file mode 100644 index 0000000..155986b --- /dev/null +++ b/IBKRTrader.slnx @@ -0,0 +1,3 @@ + + + diff --git a/Modules/CongressTrading/CongressTradingModule.cs b/Modules/CongressTrading/CongressTradingModule.cs new file mode 100644 index 0000000..44e006a --- /dev/null +++ b/Modules/CongressTrading/CongressTradingModule.cs @@ -0,0 +1,55 @@ +using IBKRTrader.Core.Workers; +using IBKRTrader.Modules.CongressTrading.Database; +using IBKRTrader.Modules.CongressTrading.Scraper; +using IBKRTrader.Modules.CongressTrading.Workers; +using Microsoft.Extensions.DependencyInjection; + +namespace IBKRTrader.Modules.CongressTrading; + +/// +/// Entry-Point des CongressTrading-Moduls. +/// Registriert alle modulspezifischen Services in den DI-Container. +/// +/// Architektur-Regel: Dieser Code wird von Program.cs aufgerufen. +/// Der Core kennt dieses Modul NICHT – die Abhängigkeit geht nur von Modul → Core. +/// +public static class CongressTradingModule +{ + public const string ModuleKey = "CT"; + + /// Registriert alle Modul-Services im DI-Container. + public static void Register(IServiceCollection services) + { + // Datenbank + services.AddSingleton(); + services.AddSingleton(); + + // Scraper + services.AddSingleton(); + + // Worker + services.AddSingleton(); + services.AddSingleton(); + } + + /// + /// Führt die Modul-Migrationen aus und resettet ggf. den Fortschritt. + /// + public static async Task InitializeAsync(IServiceProvider provider) + { + var migrations = provider.GetRequiredService(); + await migrations.RunAsync(); + + // Einmaliger Reset: löscht alle alten (fehlerhaften) Fortschritts-Daten + // damit der Import sauber von Seite 1 neu startet. + var repo = provider.GetRequiredService(); + // await repo.ResetHistoryImportAsync(); + } + + /// Gibt die Modul-Worker für die WorkerEngine zurück. + public static IEnumerable GetWorkers(IServiceProvider provider) => + [ + provider.GetRequiredService(), + provider.GetRequiredService() + ]; +} diff --git a/Modules/CongressTrading/Database/CongressMigrations.cs b/Modules/CongressTrading/Database/CongressMigrations.cs new file mode 100644 index 0000000..3f0d5e2 --- /dev/null +++ b/Modules/CongressTrading/Database/CongressMigrations.cs @@ -0,0 +1,80 @@ +using IBKRTrader.Core.Database; +using IBKRTrader.Core.Logging; + +namespace IBKRTrader.Modules.CongressTrading.Database; + +/// +/// Legt die ct_xxx-Tabellen idempotent an. +/// Namensschema: ct_{tabellenname} gemäß Architekturregeln. +/// +public class CongressMigrations +{ + private readonly DatabaseService _db; + private readonly LoggingService _logger; + + public CongressMigrations(DatabaseService db, LoggingService logger) + { + _db = db; + _logger = logger; + } + + public async Task RunAsync() + { + _logger.Info("CT", "Starte CongressTrading-Datenbankmigrationen..."); + await CreateCongressMemberTableAsync(); + await CreateCongressTradeTableAsync(); + _logger.Info("CT", "CongressTrading-Migrationen abgeschlossen."); + } + + private Task CreateCongressMemberTableAsync() => _db.ExecuteAsync(@" + CREATE TABLE IF NOT EXISTS `ct_congressMember` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `bio_id` VARCHAR(20) NOT NULL UNIQUE, + `name` VARCHAR(200), + `party` VARCHAR(50), + `state` VARCHAR(50), + `chamber` VARCHAR(10), + `profile_url` VARCHAR(500), + `first_seen` DATETIME DEFAULT CURRENT_TIMESTAMP, + `last_updated` DATETIME DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_bio_id` (`bio_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); + + private async Task CreateCongressTradeTableAsync() + { + // Tabelle anlegen + await _db.ExecuteAsync(@" + CREATE TABLE IF NOT EXISTS `ct_trade` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `trade_id` VARCHAR(20) NOT NULL UNIQUE, + `member_bio_id` VARCHAR(20) NOT NULL, + `issuer_name` VARCHAR(500), + `issuer_id` VARCHAR(20), + `ticker` VARCHAR(20), + `trade_type` VARCHAR(50), + `chamber` VARCHAR(20), + `owner` VARCHAR(50), + `value` DECIMAL(18,2), + `size_range_low` BIGINT, + `size_range_high` BIGINT, + `trade_date` DATE, + `published_date` DATE, + `detail_url` VARCHAR(500), + `details_fetched` TINYINT(1) NOT NULL DEFAULT 1, + `scraped_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_member` (`member_bio_id`), + INDEX `idx_trade_date` (`trade_date`), + INDEX `idx_ticker` (`ticker`), + INDEX `idx_details` (`details_fetched`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); + + // Idempotente Spalten-Migrationen (für bestehende DBs) + await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD COLUMN IF NOT EXISTS `details_fetched` TINYINT(1) NOT NULL DEFAULT 1;"); + await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD COLUMN IF NOT EXISTS `chamber` VARCHAR(20);"); + await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD COLUMN IF NOT EXISTS `owner` VARCHAR(50);"); + await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD COLUMN IF NOT EXISTS `value` DECIMAL(18,2);"); + await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD INDEX IF NOT EXISTS `idx_details` (`details_fetched`);"); + } +} + diff --git a/Modules/CongressTrading/Database/CongressRepository.cs b/Modules/CongressTrading/Database/CongressRepository.cs new file mode 100644 index 0000000..37e4e9c --- /dev/null +++ b/Modules/CongressTrading/Database/CongressRepository.cs @@ -0,0 +1,174 @@ +using Dapper; +using IBKRTrader.Core.Database; +using IBKRTrader.Core.Logging; +using IBKRTrader.Modules.CongressTrading.Models; + +namespace IBKRTrader.Modules.CongressTrading.Database; + +/// +/// Datenbankzugriff für das CongressTrading-Modul. +/// Kapselt alle INSERT/SELECT auf ct_congressMember und ct_trade. +/// +public class CongressRepository +{ + private readonly DatabaseService _db; + private readonly LoggingService _logger; + + public CongressRepository(DatabaseService db, LoggingService logger) + { + _db = db; + _logger = logger; + } + + // ─── CongressMember ─────────────────────────────────────────────────────── + + public async Task MemberExistsAsync(string bioId) + { + var count = await _db.ExecuteScalarAsync( + "SELECT COUNT(*) FROM `ct_congressMember` WHERE bio_id = @bioId", + new { bioId }); + return count > 0; + } + + public async Task UpsertMemberAsync(CongressMember member) + { + await _db.ExecuteAsync(@" + INSERT INTO `ct_congressMember` (bio_id, name, party, state, chamber, profile_url) + VALUES (@BioId, @Name, @Party, @State, @Chamber, @ProfileUrl) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + party = VALUES(party), + state = VALUES(state), + chamber = VALUES(chamber), + profile_url = VALUES(profile_url), + last_updated = CURRENT_TIMESTAMP", member); + } + + public async Task> GetAllMemberBioIdsAsync() + { + var ids = await _db.QueryAsync( + "SELECT bio_id FROM `ct_congressMember`"); + return ids.ToHashSet(); + } + + // ─── CongressTrade ──────────────────────────────────────────────────────── + + public async Task TradeExistsAsync(string tradeId) + { + var count = await _db.ExecuteScalarAsync( + "SELECT COUNT(*) FROM `ct_trade` WHERE trade_id = @tradeId", + new { tradeId }); + return count > 0; + } + + public async Task> GetAllTradeIdsAsync() + { + var ids = await _db.QueryAsync( + "SELECT trade_id FROM `ct_trade`"); + return ids.ToHashSet(); + } + + /// Fügt einen Trade ein. Ignoriert Duplikate (INSERT IGNORE). + public async Task InsertTradeAsync(CongressTrade trade, bool detailsFetched = true) + { + await _db.ExecuteAsync(@" + INSERT IGNORE INTO `ct_trade` + (trade_id, member_bio_id, issuer_name, issuer_id, ticker, + trade_type, chamber, owner, value, + trade_date, published_date, detail_url, details_fetched) + VALUES + (@TradeId, @MemberBioId, @IssuerName, @IssuerId, @Ticker, + @TradeType, @Chamber, @Owner, @Value, + @TradeDate, @PublishedDate, @DetailUrl, @DetailsFetched)", + new + { + trade.TradeId, + trade.MemberBioId, + trade.IssuerName, + trade.IssuerId, + trade.Ticker, + trade.TradeType, + trade.Chamber, + trade.Owner, + trade.Value, + TradeDate = trade.TradeDate?.ToString("yyyy-MM-dd"), + PublishedDate = trade.PublishedDate?.ToString("yyyy-MM-dd"), + trade.DetailUrl, + DetailsFetched = detailsFetched ? 1 : 0 + }); + } + + /// Aktualisiert die Detail-Felder eines bereits vorhandenen Trades. + public async Task UpdateTradeDetailsAsync(CongressTrade trade) + { + await _db.ExecuteAsync(@" + UPDATE `ct_trade` + SET ticker = @Ticker, + trade_type = @TradeType, + trade_date = @TradeDate, + published_date = @PublishedDate, + details_fetched = 1 + WHERE trade_id = @TradeId + AND details_fetched = 0", + new + { + trade.TradeId, + trade.Ticker, + trade.TradeType, + TradeDate = trade.TradeDate?.ToString("yyyy-MM-dd"), + PublishedDate = trade.PublishedDate?.ToString("yyyy-MM-dd") + }); + } + + /// Gibt Trade-IDs zurück für die noch keine Details geholt wurden. + public async Task> GetTradesWithoutDetailsAsync(int limit = 500) + => await _db.QueryAsync( + "SELECT trade_id FROM `ct_trade` WHERE details_fetched = 0 LIMIT @limit", + new { limit }); + + public async Task GetTradeCountAsync() + => await _db.ExecuteScalarAsync("SELECT COUNT(*) FROM `ct_trade`"); + + public async Task GetMemberCountAsync() + => await _db.ExecuteScalarAsync("SELECT COUNT(*) FROM `ct_congressMember`"); + + // ─── core_settings Flags ────────────────────────────────────────────────── + + public async Task GetSettingAsync(string key) + { + return await _db.QueryFirstOrDefaultAsync( + "SELECT `value` FROM `core_settings` WHERE `key` = @key", + new { key }); + } + + public async Task SetSettingAsync(string key, string value) + { + await _db.ExecuteAsync(@" + INSERT INTO `core_settings` (`key`, `value`) + VALUES (@key, @value) + ON DUPLICATE KEY UPDATE `value` = @value", + new { key, value }); + } + + public async Task DeleteSettingAsync(string key) + { + await _db.ExecuteAsync( + "DELETE FROM `core_settings` WHERE `key` = @key", new { key }); + } + + // ─── Reset ──────────────────────────────────────────────────────────────── + + /// + /// Löscht alle CT-Fortschritts-Flags und alle CT-Daten aus der DB. + /// Danach startet der History-Import beim nächsten Worker-Run von Seite 1. + /// + public async Task ResetHistoryImportAsync() + { + await DeleteSettingAsync("ct.history_import_done"); + await DeleteSettingAsync("ct.history_import_page"); + await DeleteSettingAsync("ct.history_import_total"); + await _db.ExecuteAsync("DELETE FROM `ct_trade`"); + await _db.ExecuteAsync("DELETE FROM `ct_congressMember`"); + _logger.Warn("CT", "History-Import-Reset durchgeführt – alle CT-Daten gelöscht."); + } +} diff --git a/Modules/CongressTrading/Models/CongressMember.cs b/Modules/CongressTrading/Models/CongressMember.cs new file mode 100644 index 0000000..c0ef6d5 --- /dev/null +++ b/Modules/CongressTrading/Models/CongressMember.cs @@ -0,0 +1,15 @@ +namespace IBKRTrader.Modules.CongressTrading.Models; + +/// Kongressmitglied aus capitoltrades.com +public class CongressMember +{ + public long Id { get; set; } + public string BioId { get; set; } = ""; // z.B. "W000805" + public string Name { get; set; } = ""; + public string Party { get; set; } = ""; // Democrat/Republican/Independent + public string State { get; set; } = ""; + public string Chamber { get; set; } = ""; // House/Senate + public string ProfileUrl { get; set; } = ""; + public DateTime FirstSeen { get; set; } = DateTime.UtcNow; + public DateTime LastUpdated { get; set; } = DateTime.UtcNow; +} diff --git a/Modules/CongressTrading/Models/CongressTrade.cs b/Modules/CongressTrading/Models/CongressTrade.cs new file mode 100644 index 0000000..ae0e2cd --- /dev/null +++ b/Modules/CongressTrading/Models/CongressTrade.cs @@ -0,0 +1,23 @@ +namespace IBKRTrader.Modules.CongressTrading.Models; + +/// Ein einzelner Kongress-Trade aus capitoltrades.com +public class CongressTrade +{ + public long Id { get; set; } + public string TradeId { get; set; } = ""; + public string MemberBioId { get; set; } = ""; + public string IssuerName { get; set; } = ""; + public string IssuerId { get; set; } = ""; + public string Ticker { get; set; } = ""; + public string TradeType { get; set; } = ""; // buy/sell/exchange + public string Chamber { get; set; } = ""; // house/senate + public string Owner { get; set; } = ""; // self/spouse/child/trust + public decimal? Value { get; set; } // exakter Handelswert in USD + public DateOnly? TradeDate { get; set; } + public DateOnly? PublishedDate { get; set; } + public string DetailUrl { get; set; } = ""; + public DateTime ScrapedAt { get; set; } = DateTime.UtcNow; + + /// Transient – wird direkt in ct_congressMember gespeichert. + public CongressMember? MemberSnapshot { get; set; } +} diff --git a/Modules/CongressTrading/Scraper/CapitolTradesScraper.cs b/Modules/CongressTrading/Scraper/CapitolTradesScraper.cs new file mode 100644 index 0000000..9a7dc73 --- /dev/null +++ b/Modules/CongressTrading/Scraper/CapitolTradesScraper.cs @@ -0,0 +1,332 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using IBKRTrader.Core.Logging; +using IBKRTrader.Modules.CongressTrading.Models; + +namespace IBKRTrader.Modules.CongressTrading.Scraper; + +// ─── JSON-Modelle (exakte Feldnamen aus dem RSC-Stream) ────────────────────── + +internal class CtApiWrapper +{ + [JsonPropertyName("data")] public List? Data { get; set; } + [JsonPropertyName("meta")] public CtMeta? Meta { get; set; } +} + +internal class CtMeta +{ + [JsonPropertyName("totalPages")] public int TotalPages { get; set; } + [JsonPropertyName("pageSize")] public int PageSize { get; set; } + [JsonPropertyName("totalCount")] public int TotalCount { get; set; } + [JsonPropertyName("page")] public int Page { get; set; } +} + +internal class CtTrade +{ + [JsonPropertyName("_txId")] public long TxId { get; set; } + [JsonPropertyName("txDate")] public string? TxDate { get; set; } + [JsonPropertyName("pubDate")] public string? PubDate { get; set; } + [JsonPropertyName("txType")] public string? TxType { get; set; } + [JsonPropertyName("value")] public decimal? Value { get; set; } + [JsonPropertyName("price")] public decimal? Price { get; set; } + [JsonPropertyName("owner")] public string? Owner { get; set; } + [JsonPropertyName("chamber")] public string? Chamber { get; set; } + [JsonPropertyName("reportingGap")] public int? ReportingGap { get; set; } + [JsonPropertyName("comment")] public string? Comment { get; set; } + [JsonPropertyName("_politicianId")] public string? PoliticianId { get; set; } + [JsonPropertyName("_issuerId")] public long IssuerId { get; set; } + [JsonPropertyName("politician")] public CtPolitician? Politician { get; set; } + [JsonPropertyName("issuer")] public CtIssuer? Issuer { get; set; } +} + +internal class CtPolitician +{ + [JsonPropertyName("firstName")] public string? FirstName { get; set; } + [JsonPropertyName("lastName")] public string? LastName { get; set; } + [JsonPropertyName("party")] public string? Party { get; set; } + [JsonPropertyName("_stateId")] public string? StateId { get; set; } + [JsonPropertyName("chamber")] public string? Chamber { get; set; } +} + +internal class CtIssuer +{ + [JsonPropertyName("issuerName")] public string? IssuerName { get; set; } + [JsonPropertyName("issuerTicker")] public string? IssuerTicker { get; set; } + [JsonPropertyName("sector")] public string? Sector { get; set; } +} + +// ─── Scraper ───────────────────────────────────────────────────────────────── + +/// +/// Scrapt capitoltrades.com über den eingebetteten Next.js RSC-Datenstrom. +/// +/// Die Seite liefert Daten als escaped JSON-String innerhalb von: +/// self.__next_f.push([1,"...\"data\":[{\"_txId\":...}]..."]) +/// +/// Strategie: +/// 1. HTML-Seite abrufen +/// 2. Den escaped JSON-String mit dem "data"-Array extrahieren +/// 3. String un-escapen → gültiges JSON +/// 4. Als Liste von CtTrade deserialisieren +/// +/// Rate-Limiting: 600ms zwischen Requests. +/// +public class CapitolTradesScraper +{ + private readonly HttpClient _http; + private readonly LoggingService _logger; + + private const string BaseUrl = "https://www.capitoltrades.com"; + private const int RateDelayMs = 600; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }; + + // Sucht das "data":[ Array im escaped RSC-String + // Das Muster ist: \"data\":[{\"_issuerId\":...,\"_txId\":...}] + private static readonly Regex DataArrayRx = new( + @"""data"":\s*\[", + RegexOptions.Compiled); + + // Pagination: \"totalPages\":N + private static readonly Regex TotalPagesRx = new( + @"""totalPages""\s*:\s*(\d+)", + RegexOptions.Compiled); + + public CapitolTradesScraper(LoggingService logger) + { + _logger = logger; + _http = new HttpClient(); + _http.DefaultRequestHeaders.Add("User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"); + _http.DefaultRequestHeaders.Add("Accept-Language", "en-US,en;q=0.9"); + _http.DefaultRequestHeaders.Add("Accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); + _http.Timeout = TimeSpan.FromSeconds(30); + } + + // ─── Öffentliche API ───────────────────────────────────────────────────── + + public async Task GetTotalPagesAsync(CancellationToken ct) + { + await RateDelay(ct); + var html = await _http.GetStringAsync($"{BaseUrl}/trades?pageSize=96&page=1", ct); + var unescaped = UnescapeHtmlPayload(html); + var match = TotalPagesRx.Match(unescaped); + + if (match.Success) + { + var total = int.Parse(match.Groups[1].Value); + _logger.Info("CT", $"GetTotalPagesAsync: {total} Seiten gefunden."); + return total; + } + + _logger.Warn("CT", "GetTotalPagesAsync: Konnte 'totalPages' im RSC-Stream nicht finden."); + _logger.Info("CT", $" Payload-Länge: {unescaped.Length}. Erste 200 Zeichen: {unescaped.Substring(0, Math.Min(200, unescaped.Length))}"); + + // Fallback: Wenn wir zumindest Trades gefunden haben, nehmen wir an es gibt mindestens 1 Seite. + // Falls wir totalPages nicht finden, riskieren wir hier 1, aber wir haben gewarnt. + return 1; + } + + + public async Task<(List Trades, HashSet MemberBioIds)> + ScrapeTradesPageAsync(int page, CancellationToken ct) + { + await RateDelay(ct); + + var url = $"{BaseUrl}/trades?pageSize=96&page={page}"; + var html = await _http.GetStringAsync(url, ct); + + var trades = new List(); + var memberIds = new HashSet(); + + // HTML un-escapen → gültiges JSON + var payload = UnescapeHtmlPayload(html); + + // "data":[ Block finden + var rawTrades = ExtractTradeArray(payload); + + if (rawTrades == null || rawTrades.Count == 0) + { + _logger.Warn("CT", $"Seite {page}: Keine Trade-Daten im Payload gefunden."); + _logger.Info("CT", $" Payload-Länge: {payload.Length}, HTML-Länge: {html.Length}"); + return (trades, memberIds); + } + + foreach (var raw in rawTrades) + { + if (raw.TxId == 0) continue; + + var bioId = raw.PoliticianId ?? ""; + if (!string.IsNullOrEmpty(bioId)) + memberIds.Add(bioId); + + // Ticker: "AVGO:US" → "AVGO" + var ticker = raw.Issuer?.IssuerTicker ?? ""; + if (ticker.Contains(':')) + ticker = ticker[..ticker.IndexOf(':')]; + + var trade = new CongressTrade + { + TradeId = raw.TxId.ToString(), + MemberBioId = bioId, + IssuerName = raw.Issuer?.IssuerName ?? "", + IssuerId = raw.IssuerId.ToString(), + Ticker = ticker, + TradeType = raw.TxType ?? "", + Chamber = raw.Chamber ?? "", + Owner = raw.Owner ?? "", + Value = raw.Value, + TradeDate = ParseDate(raw.TxDate), + PublishedDate = ParseDate(raw.PubDate), + DetailUrl = $"{BaseUrl}/trades/{raw.TxId}" + }; + + if (raw.Politician != null && !string.IsNullOrEmpty(bioId)) + { + trade.MemberSnapshot = new CongressMember + { + BioId = bioId, + Name = $"{raw.Politician.FirstName} {raw.Politician.LastName}".Trim(), + Party = CapFirst(raw.Politician.Party ?? ""), + State = (raw.Politician.StateId ?? "").ToUpperInvariant(), + Chamber = CapFirst(raw.Politician.Chamber ?? ""), + ProfileUrl = $"{BaseUrl}/politicians/{bioId}" + }; + } + + trades.Add(trade); + } + + _logger.Info("CT", $"Seite {page}: {trades.Count} Trades extrahiert."); + return (trades, memberIds); + } + + // ─── Hilfsmethoden ──────────────────────────────────────────────────────── + + /// + /// Extrahiert und un-escaped den JSON-Payload aus dem RSC-Stream. + /// + /// Die Seite enthält Blöcke wie: + /// self.__next_f.push([1,"...ESCAPED_JSON..."]) + /// + /// Die inneren Anführungszeichen sind mit \" escaped. + /// Wir konkatenieren alle Chunk-Strings und un-escapen sie. + /// + private static string UnescapeHtmlPayload(string html) + { + // Alle self.__next_f.push([1,"..."]) Chunks sammeln + // Regex: sucht [1,"(content)"] Blöcke + var sb = new System.Text.StringBuilder(); + + var searchFrom = 0; + const string marker = "self.__next_f.push([1,\""; + + while (true) + { + var idx = html.IndexOf(marker, searchFrom, StringComparison.Ordinal); + if (idx < 0) break; + + // Start des String-Inhalts + var contentStart = idx + marker.Length; + + // Ende des Strings finden: suche das schließende "]) + // Dabei escaped \" ignorieren + var contentEnd = FindEscapedStringEnd(html, contentStart); + if (contentEnd < 0) break; + + var chunk = html[contentStart..contentEnd]; + + // Nur das äußere JSON-String-Escaping rückgängig machen: + // \" → " (Anführungszeichen) + // \/ → / (Forward-Slash, optional aber häufig) + // Unicode-Escapes (\u003e etc.) lässt der JSON-Deserializer selbst auf. + chunk = chunk.Replace("\\\"", "\"").Replace("\\/", "/"); + + sb.Append(chunk); + searchFrom = contentEnd + 2; // skip "]) + } + + return sb.ToString(); + } + + /// Findet das Ende eines JSON-Strings (schließendes " das nicht escaped ist). + private static int FindEscapedStringEnd(string s, int start) + { + for (var i = start; i < s.Length; i++) + { + if (s[i] == '\\') { i++; continue; } // überspringe escaped Zeichen + if (s[i] == '"') return i; + } + return -1; + } + + /// Extrahiert das "data":[...] Array aus dem un-escaped Payload. + private static List? ExtractTradeArray(string payload) + { + var match = DataArrayRx.Match(payload); + if (!match.Success) return null; + + // Ab "[" das balancierte Array extrahieren + var arrayStart = match.Index + match.Length - 1; // auf dem "[" + var json = ExtractBalancedBracket(payload, arrayStart, '[', ']'); + if (json == null) return null; + + try + { + return JsonSerializer.Deserialize>(json, JsonOpts); + } + catch + { + return null; + } + } + + private static string? ExtractBalancedBracket(string s, int start, char open, char close) + { + if (start >= s.Length || s[start] != open) return null; + + var depth = 0; + var inStr = false; + var escape = false; + + for (var i = start; i < s.Length; i++) + { + var c = s[i]; + + if (escape) { escape = false; continue; } + if (c == '\\' && inStr) { escape = true; continue; } + if (c == '"') { inStr = !inStr; continue; } + if (inStr) continue; + + if (c == open) depth++; + else if (c == close) + { + depth--; + if (depth == 0) return s[start..(i + 1)]; + } + } + + return null; + } + + private static DateOnly? ParseDate(string? s) + { + if (string.IsNullOrEmpty(s)) return null; + var datePart = s.Length > 10 ? s[..10] : s; + return DateOnly.TryParse(datePart, out var d) ? d : null; + } + + private static string CapFirst(string s) + => string.IsNullOrEmpty(s) ? s + : char.ToUpperInvariant(s[0]) + s[1..].ToLowerInvariant(); + + private static Task RateDelay(CancellationToken ct) + => Task.Delay(RateDelayMs, ct); +} diff --git a/Modules/CongressTrading/Workers/CongressHistoryImportWorker.cs b/Modules/CongressTrading/Workers/CongressHistoryImportWorker.cs new file mode 100644 index 0000000..2757275 --- /dev/null +++ b/Modules/CongressTrading/Workers/CongressHistoryImportWorker.cs @@ -0,0 +1,138 @@ +using IBKRTrader.Core.Database; +using IBKRTrader.Core.Logging; +using IBKRTrader.Modules.CongressTrading.Database; +using IBKRTrader.Modules.CongressTrading.Scraper; +using IBKRTrader.Core.Workers; + +namespace IBKRTrader.Modules.CongressTrading.Workers; + +/// +/// Einmaliger History-Import – übersteht beliebig viele Neustarts. +/// +/// Fortschritts-Persistenz via core_settings: +/// ct.history_import_done = "true" → abgeschlossen, Worker skippt sich selbst +/// ct.history_import_page = "N" → nächste zu verarbeitende Seite +/// ct.history_import_total = "N" → Gesamtseitenanzahl (gecacht) +/// +/// Alle Trade-Daten (Datum, Typ, Value, Ticker, Mitglied) kommen direkt +/// aus dem Listen-JSON – kein separates Detail-Scraping notwendig. +/// +public class CongressHistoryImportWorker : WorkerBase +{ + private readonly CapitolTradesScraper _scraper; + private readonly CongressRepository _repo; + + private const string DoneFlag = "ct.history_import_done"; + private const string ProgressKey = "ct.history_import_page"; + private const string TotalKey = "ct.history_import_total"; + + public override string Name => "CT-HistoryImport"; + public override string Module => "CT"; + public override WorkerType Type => WorkerType.Worker; + + protected override TimeSpan? Interval => TimeSpan.FromDays(365); + + public CongressHistoryImportWorker( + LoggingService logger, + DatabaseService db, + CapitolTradesScraper scraper, + CongressRepository repo) + : base(logger, db) + { + _scraper = scraper; + _repo = repo; + Info.Info = "Bereit – prüfe Fortschritt beim Start..."; + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + // ── 1. Bereits vollständig abgeschlossen? ──────────────────────────── + if (await _repo.GetSettingAsync(DoneFlag) == "true") + { + Logger.Info(Module, "CT History-Import: bereits abgeschlossen."); + Info.Info = "✓ Abgeschlossen"; + return; + } + + // ── 2. Fortschritt aus DB laden ────────────────────────────────────── + var pageStr = await _repo.GetSettingAsync(ProgressKey); + var startPage = pageStr != null && int.TryParse(pageStr, out var sp) ? sp : 1; + + var totalStr = await _repo.GetSettingAsync(TotalKey); + int totalPages; + + if (totalStr != null && int.TryParse(totalStr, out var tp) && tp > 0) + { + totalPages = tp; + Logger.Info(Module, + $"▶ Fortsetze History-Import: Seite {startPage} von {totalPages} (Gesamtanzahl aus Cache)"); + } + else + { + Logger.Info(Module, "Ermittle Gesamtseitenanzahl von capitoltrades.com..."); + totalPages = await _scraper.GetTotalPagesAsync(ct); + await _repo.SetSettingAsync(TotalKey, totalPages.ToString()); + Logger.Info(Module, + $"▶ Starte History-Import: {totalPages} Seiten gefunden. Beginne bei Seite {startPage}."); + } + + + // ── 3. Seiten paginieren ───────────────────────────────────────────── + var knownMembers = await _repo.GetAllMemberBioIdsAsync(); + var importedTotal = 0; + + for (var page = startPage; page <= totalPages; page++) + { + ct.ThrowIfCancellationRequested(); + + Info.Info = $"Seite {page}/{totalPages} – {importedTotal} neue Trades"; + + var (trades, memberIds) = await _scraper.ScrapeTradesPageAsync(page, ct); + + if (trades.Count == 0) + { + Logger.Warn(Module, + $"[{page}/{totalPages}] Leere Seite – Seite wird übersprungen."); + await _repo.SetSettingAsync(ProgressKey, (page + 1).ToString()); + continue; + } + + // Neue Kongressmitglieder anlegen (Daten kommen aus MemberSnapshot) + foreach (var trade in trades.Where(t => t.MemberSnapshot != null)) + { + var snapshot = trade.MemberSnapshot!; + if (knownMembers.Contains(snapshot.BioId)) continue; + + await _repo.UpsertMemberAsync(snapshot); + knownMembers.Add(snapshot.BioId); + Logger.Info(Module, + $" + {snapshot.Name} ({snapshot.BioId}) {snapshot.Party}/{snapshot.State}"); + } + + // Trades einfügen (INSERT IGNORE – Duplikate werden automatisch ignoriert) + foreach (var trade in trades) + { + ct.ThrowIfCancellationRequested(); + if (await _repo.TradeExistsAsync(trade.TradeId)) continue; + await _repo.InsertTradeAsync(trade, detailsFetched: true); + importedTotal++; + } + + // ─── Fortschritt nach jeder erfolgreichen Seite persistieren ─── + await _repo.SetSettingAsync(ProgressKey, (page + 1).ToString()); + + Logger.Info(Module, + $"[{page}/{totalPages}] {trades.Count} Trades, {importedTotal} neu gesamt."); + } + + // ── 4. Abschluss ───────────────────────────────────────────────────── + await _repo.SetSettingAsync(DoneFlag, "true"); + + var totalInDb = await _repo.GetTradeCountAsync(); + var membersInDb = await _repo.GetMemberCountAsync(); + + Logger.Info(Module, + $"=== History-Import abgeschlossen: {totalInDb} Trades, {membersInDb} Mitglieder ==="); + Info.Info = $"✓ Fertig – {totalInDb} Trades, {membersInDb} Mitglieder"; + } +} diff --git a/Modules/CongressTrading/Workers/CongressScrapeWorker.cs b/Modules/CongressTrading/Workers/CongressScrapeWorker.cs new file mode 100644 index 0000000..a5f2c95 --- /dev/null +++ b/Modules/CongressTrading/Workers/CongressScrapeWorker.cs @@ -0,0 +1,82 @@ +using IBKRTrader.Core.Database; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Settings; +using IBKRTrader.Modules.CongressTrading.Database; +using IBKRTrader.Modules.CongressTrading.Scraper; +using IBKRTrader.Core.Workers; + +namespace IBKRTrader.Modules.CongressTrading.Workers; + +/// +/// Periodischer Scrape-Worker: alle 30 Minuten. +/// Lädt nur Seite 1 (neueste 96 Trades) und prüft auf neue Einträge. +/// Alle Daten (Datum, Typ, Value, Ticker, Mitglied) kommen direkt aus dem RSC-JSON. +/// +public class CongressScrapeWorker : WorkerBase +{ + private readonly CapitolTradesScraper _scraper; + private readonly CongressRepository _repo; + + public override string Name => "CT-ScrapeWorker"; + public override string Module => "CT"; + public override WorkerType Type => WorkerType.Worker; + + protected override TimeSpan? Interval => TimeSpan.FromMinutes(30); + + public CongressScrapeWorker( + LoggingService logger, + DatabaseService db, + CapitolTradesScraper scraper, + CongressRepository repo, + SettingsService settings) + : base(logger, db) + { + _scraper = scraper; + _repo = repo; + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + Logger.Info(Module, "Suche neue Kongress-Trades (Seite 1)..."); + + var knownMembers = await _repo.GetAllMemberBioIdsAsync(); + var (trades, _) = await _scraper.ScrapeTradesPageAsync(1, ct); + + if (trades.Count == 0) + { + Logger.Warn(Module, "Scrape: Keine Trades auf Seite 1 gefunden."); + Info.Info = $"Letzter Lauf {DateTime.Now:HH:mm}: 0 Trades"; + return; + } + + // Neue Kongressmitglieder + var newMembers = 0; + foreach (var trade in trades.Where(t => t.MemberSnapshot != null)) + { + var snapshot = trade.MemberSnapshot!; + if (knownMembers.Contains(snapshot.BioId)) continue; + + await _repo.UpsertMemberAsync(snapshot); + knownMembers.Add(snapshot.BioId); + Logger.Info(Module, $"Neues Mitglied: {snapshot.Name} ({snapshot.BioId})"); + newMembers++; + } + + // Neue Trades + var newTrades = 0; + foreach (var trade in trades) + { + ct.ThrowIfCancellationRequested(); + if (await _repo.TradeExistsAsync(trade.TradeId)) continue; + + await _repo.InsertTradeAsync(trade, detailsFetched: true); + Logger.Info(Module, + $" + {trade.IssuerName} ({trade.TradeType}) [{trade.Ticker}] von {trade.MemberBioId}"); + newTrades++; + } + + var summary = $"{newTrades} neue Trades, {newMembers} neue Mitglieder"; + Logger.Info(Module, $"Scrape abgeschlossen: {summary}"); + Info.Info = $"Letzter Lauf {DateTime.Now:HH:mm}: {summary}"; + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..8c1715b --- /dev/null +++ b/Program.cs @@ -0,0 +1,85 @@ +using IBKRTrader.Core.AI; +using IBKRTrader.Core.Budget; +using IBKRTrader.Core.Database; +using IBKRTrader.Core.Database.Migrations; +using IBKRTrader.Core.IBKR; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Settings; +using IBKRTrader.Core.Trading; +using IBKRTrader.Core.Workers; +using IBKRTrader.Core.Workers.BuiltIn; +using IBKRTrader.Modules.CongressTrading; +using Microsoft.Extensions.DependencyInjection; + +namespace IBKRTrader; + +internal static class Program +{ + [STAThread] + static void Main() + { + ApplicationConfiguration.Initialize(); + + // ── DI-Container aufbauen ──────────────────────────────────────────── + var services = new ServiceCollection(); + + // Core: Settings (zuerst laden) + var settingsService = new SettingsService(); + settingsService.Load(); + services.AddSingleton(settingsService); + + // Core: Logging + services.AddSingleton(); + + // Core: Database + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Core: IBKR Services + services.AddSingleton(); + services.AddSingleton(); + + // Core: Other Services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Core: Built-In Worker + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // ── Module registrieren ────────────────────────────────────────────── + CongressTradingModule.Register(services); + + // ── IWorker-Sammlung: Core + Module ────────────────────────────────── + services.AddSingleton>(sp => new IWorker[] + { + // Core-Worker + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + // IBKR Market Data Worker + sp.GetRequiredService(), + sp.GetRequiredService(), + } + // Modul-Worker dynamisch anhängen + .Concat(CongressTradingModule.GetWorkers(sp)) + .ToArray()); + + services.AddSingleton(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + + // WebApiService bekommt Engine-Referenz (zirkuläre Abhängigkeit auflösen) + var webApi = provider.GetRequiredService(); + var engine = provider.GetRequiredService(); + webApi.SetEngine(engine); + + Application.Run(provider.GetRequiredService()); + } +} \ No newline at end of file diff --git a/UI/LogPanelController.cs b/UI/LogPanelController.cs new file mode 100644 index 0000000..4019b27 --- /dev/null +++ b/UI/LogPanelController.cs @@ -0,0 +1,33 @@ +using IBKRTrader.Core.Logging; + +namespace IBKRTrader.UI; + +/// +/// Steuert das Log-Panel (RichTextBox im Logs-Tab). +/// Bietet Clear- und Filter-Funktionalität. +/// +public class LogPanelController +{ + private readonly RichTextBox _rtb; + private readonly LoggingService _logger; + + public LogPanelController(RichTextBox rtb, LoggingService logger) + { + _rtb = rtb; + _logger = logger; + + // Logging-Service mit RichTextBox verbinden + _logger.AttachRichTextBox(rtb); + + // Hintergrund der RTB auf dunkles Theme setzen + _rtb.BackColor = Color.FromArgb(20, 20, 30); + _rtb.ForeColor = Color.FromArgb(200, 200, 200); + _rtb.Font = new Font("Consolas", 9f); + _rtb.ReadOnly = true; + _rtb.WordWrap = false; + } + + public void Clear() => _rtb.Clear(); + + public void CopyAll() => Clipboard.SetText(_rtb.Text); +} diff --git a/UI/WorkerListBindingSource.cs b/UI/WorkerListBindingSource.cs new file mode 100644 index 0000000..0b03d6b --- /dev/null +++ b/UI/WorkerListBindingSource.cs @@ -0,0 +1,77 @@ +using System.ComponentModel; +using IBKRTrader.Core.Workers; + +namespace IBKRTrader.UI; + +/// +/// Richtet dgv_workerlist vollständig ein: +/// Spalten, Binding, Formatierung, Kontext-Menü. +/// +public static class WorkerListBindingSource +{ + public static void Setup(DataGridView dgv, BindingList source) + { + dgv.AutoGenerateColumns = false; + dgv.ReadOnly = false; + dgv.AllowUserToAddRows = false; + dgv.RowHeadersVisible = false; + dgv.SelectionMode = DataGridViewSelectionMode.FullRowSelect; + dgv.MultiSelect = false; + + // ── Spalten definieren ─────────────────────────────────────────────── + dgv.Columns.Clear(); + + dgv.Columns.Add(new DataGridViewCheckBoxColumn + { + DataPropertyName = nameof(WorkerInfo.Active), + HeaderText = "Active", + Width = 65, + ReadOnly = false + }); + + dgv.Columns.Add(MakeTextColumn(nameof(WorkerInfo.Type), "Type", 80, false)); + dgv.Columns.Add(MakeTextColumn(nameof(WorkerInfo.Module), "Module", 90, false)); + dgv.Columns.Add(MakeTextColumn(nameof(WorkerInfo.WorkerName), "Worker Name", 180, false)); + + dgv.Columns.Add(new DataGridViewTextBoxColumn + { + DataPropertyName = nameof(WorkerInfo.LastRuntime), + HeaderText = "Last Runtime", + Width = 150, + ReadOnly = true, + DefaultCellStyle = { Format = "dd.MM.yyyy HH:mm:ss", NullValue = "–" } + }); + + dgv.Columns.Add(new DataGridViewTextBoxColumn + { + DataPropertyName = nameof(WorkerInfo.NextRuntime), + HeaderText = "Next Runtime", + Width = 150, + ReadOnly = true, + DefaultCellStyle = { Format = "dd.MM.yyyy HH:mm:ss", NullValue = "–" } + }); + + dgv.Columns.Add(MakeTextColumn(nameof(WorkerInfo.RunEvery), "Run Every", 90, false)); + dgv.Columns.Add(MakeTextColumn(nameof(WorkerInfo.Info), "Info", 400, false)); + + // ── Binding ────────────────────────────────────────────────────────── + var bs = new BindingSource { DataSource = source }; + dgv.DataSource = bs; + + // ── Styling ────────────────────────────────────────────────────────── + dgv.EnableHeadersVisualStyles = false; + dgv.ColumnHeadersDefaultCellStyle.Font = new Font("Segoe UI", 9f, FontStyle.Bold); + dgv.DefaultCellStyle.Font = new Font("Segoe UI", 9f); + dgv.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(240, 240, 255); + dgv.GridColor = Color.LightGray; + } + + private static DataGridViewTextBoxColumn MakeTextColumn( + string prop, string header, int width, bool readOnly) => new() + { + DataPropertyName = prop, + HeaderText = header, + Width = width, + ReadOnly = readOnly + }; +} diff --git a/analyze_ct.ps1 b/analyze_ct.ps1 new file mode 100644 index 0000000..180787d --- /dev/null +++ b/analyze_ct.ps1 @@ -0,0 +1,58 @@ +$content = Get-Content 'j:\Softwareprojekte\IBKRTrader\IBKRTrader\ct_raw.html' -Raw + +# Simuliere UnescapeHtmlPayload +$marker = 'self.__next_f.push([1,"' +$searchFrom = 0 +$allPayload = "" + +while ($true) { + $idx = $content.IndexOf($marker, $searchFrom) + if ($idx -lt 0) { break } + + $contentStart = $idx + $marker.Length + + # Finde Ende des Strings (nicht-escaped ") + $end = -1 + $i = $contentStart + while ($i -lt $content.Length) { + if ($content[$i] -eq '\' ) { $i += 2; continue } + if ($content[$i] -eq '"' ) { $end = $i; break } + $i++ + } + + if ($end -lt 0) { break } + + $chunk = $content.Substring($contentStart, $end - $contentStart) + # Un-escape \" → " + $chunk = $chunk -replace '\\"', '"' + $chunk = $chunk -replace '\\/', '/' + $allPayload += $chunk + $searchFrom = $end + 2 +} + +Write-Host "Payload length: $($allPayload.Length)" + +# Finde "data":[ +$dataIdx = $allPayload.IndexOf('"data":[') +Write-Host "data array at: $dataIdx" + +if ($dataIdx -gt 0) { + Write-Host "=== data array preview (first 500 chars) ===" + $start = $dataIdx + $len = [Math]::Min(500, $allPayload.Length - $start) + Write-Host $allPayload.Substring($start, $len) + + # totalPages + $tpMatch = [regex]::Match($allPayload, '"totalPages"\s*:\s*(\d+)') + if ($tpMatch.Success) { + Write-Host "" + Write-Host "=== totalPages: $($tpMatch.Groups[1].Value) ===" + } + + # _txId Anzahl zählen + $txCount = ([regex]::Matches($allPayload, '"_txId"')).Count + Write-Host "=== _txId Vorkommen: $txCount ===" +} else { + Write-Host "=== KEIN data Array - zeige erste 1000 chars des Payload ===" + Write-Host $allPayload.Substring(0, [Math]::Min(1000, $allPayload.Length)) +} diff --git a/ct_raw.html b/ct_raw.html new file mode 100644 index 0000000..b09e4a2 --- /dev/null +++ b/ct_raw.html @@ -0,0 +1 @@ +Track US politician stock trades

Loading ...

\ No newline at end of file diff --git a/grok.txt b/grok.txt new file mode 100644 index 0000000..3e1fab1 --- /dev/null +++ b/grok.txt @@ -0,0 +1,129 @@ +Deine Datenbankstruktur sollte genau auf die IBKR-Web-API abgestimmt sein – vor allem auf den zentralen conid (Contract ID). +Ich habe mir die komplette Web-API-Dokumentation zum Tag „Trading-Contracts“ (und die damit verknüpften Endpoints /iserver/secdef/search, /trsrv/stocks, /trsrv/secdef etc.) angeschaut. Die wichtigsten Erkenntnisse daraus: + +Symbol allein reicht nie (AAPL gibt es z. B. auf NASDAQ, MEXI, LSE etc.). +Der conid ist der einzige stabile, eindeutige Schlüssel in IBKR (z. B. AAPL US Stock = conid: 265598). Er ändert sich nie und wird für alle Market-Data-, Order- und Positions-Calls verwendet. +IBKR liefert über die Search-Endpoints automatisch companyName, sector, primaryExchange, validExchanges etc. mit – das kannst du direkt in die DB schreiben. +Historische Daten (und alle Trades) referenzieren immer nur den conid. + +Genau das nutzen wir für die Datenbank. Ich schlage eine saubere, skalierbare und zukunftssichere Struktur vor (PostgreSQL + TimescaleDB für die Zeitreihen ist ideal, funktioniert aber auch mit normalem Postgres). +1. Kern-Tabellen (relational) +SQL-- 1. Instruments = IBKR-Contract (der zentrale „Single Source of Truth“) +CREATE TABLE instruments ( + id BIGSERIAL PRIMARY KEY, + ibkr_conid BIGINT NOT NULL UNIQUE, -- wichtigster Key! + symbol VARCHAR(20) NOT NULL, + sec_type VARCHAR(10) NOT NULL, -- STK, OPT, FUT, BOND ... + exchange VARCHAR(20) NOT NULL, -- NASDAQ, SMART, NYSE ... + primary_exchange VARCHAR(20), + currency VARCHAR(3) NOT NULL, + company_name TEXT, + local_symbol VARCHAR(20), + trading_class VARCHAR(20), + isin VARCHAR(12), -- oft nützlich + sector VARCHAR(100), + industry VARCHAR(100), + description TEXT, + has_options BOOLEAN, + asset_class VARCHAR(10), + listing_exchange VARCHAR(20), + last_fetched TIMESTAMPTZ DEFAULT NOW(), + active BOOLEAN DEFAULT true, + custom_metadata JSONB, -- flexibler Platz für alles, was du später brauchst + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 2. External Identifiers = Verknüpfung zu allen anderen Quellen +CREATE TABLE external_identifiers ( + id BIGSERIAL PRIMARY KEY, + instrument_id BIGINT REFERENCES instruments(id) ON DELETE CASCADE, + source VARCHAR(50) NOT NULL, -- 'yahoo', 'alpha_vantage', 'bloomberg', 'web_scrape', 'manual', 'tradingview' ... + ticker VARCHAR(50) NOT NULL, + additional_params JSONB, -- z. B. {'exchange': 'NYSE', 'region': 'US'} + priority SMALLINT DEFAULT 1, -- falls mehrere Ticker für dieselbe Quelle existieren + last_verified TIMESTAMPTZ, + UNIQUE (instrument_id, source, ticker) +); + +-- Index für schnelle Lookups +CREATE INDEX idx_external_source_ticker ON external_identifiers(source, ticker); +CREATE INDEX idx_instrument_conid ON instruments(ibkr_conid); +Warum diese Trennung? +Du bekommst z. B. von einer Web-Analyse oder einem Scraping-Tool den Ticker „AAPL“. Du schaust in external_identifiers nach → findest sofort das instrument_id → hast den ibkr_conid und alle IBKR-spezifischen Daten. +2. Eigene Informationen & Analysen (sehr flexibel) +Du kannst zwei Wege gehen – ich empfehle beide gleichzeitig: +Variante A (einfach & schnell): Die Spalte custom_metadata JSONB in der instruments-Tabelle (siehe oben). Dort kannst du beliebig strukturierte eigene Daten speichern: +JSON{ + "ai_sentiment_score": 0.87, + "my_strategy_rating": "strong_buy", + "notes": "Apple AI-Investitionen 2025 sehr interessant", + "last_ai_analysis": "2026-05-10", + "custom_pe_target": 32.5, + "watchlist_tags": ["growth", "tech", "ai"] +} +Variante B (noch flexibler, für sehr viele eigene Felder): Eine separate EAV-Tabelle: +SQLCREATE TABLE instrument_custom_data ( + id BIGSERIAL PRIMARY KEY, + instrument_id BIGINT REFERENCES instruments(id) ON DELETE CASCADE, + key VARCHAR(100) NOT NULL, -- z. B. 'ai_sentiment', 'my_risk_score' + value JSONB, + source VARCHAR(20), -- 'user', 'gpt-4o', 'claude' ... + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (instrument_id, key) +); +3. Historische Marktdaten (Time-Series) +SQL-- Mit TimescaleDB (empfohlen) oder normaler Postgres-Tabelle +CREATE TABLE market_data ( + instrument_id BIGINT REFERENCES instruments(id) ON DELETE CASCADE, + data_source VARCHAR(20) DEFAULT 'ibkr', -- 'ibkr', 'yahoo', 'polygon' ... + bar_size VARCHAR(10) NOT NULL, -- '1min', '5min', '15min', '1hour', 'daily', 'weekly' ... + timestamp TIMESTAMPTZ NOT NULL, + open NUMERIC(12,6), + high NUMERIC(12,6), + low NUMERIC(12,6), + close NUMERIC(12,6), + volume BIGINT, + wap NUMERIC(12,6), -- weighted average price (IBKR) + count INTEGER, -- Anzahl Trades in der Bar + additional_data JSONB, -- falls andere Quellen mehr Felder liefern + PRIMARY KEY (instrument_id, data_source, bar_size, timestamp) +); + +-- Timescale-Hypertable (sehr performant bei Millionen von Zeilen) +SELECT create_hypertable('market_data', 'timestamp', chunk_time_interval => INTERVAL '7 days'); +Zukunftssicherheit: +Durch data_source kannst du später problemlos Polygon, Yahoo, Alpha Vantage etc. hinzufügen, ohne die Struktur zu ändern. +4. Zusätzliche nützliche Tabellen (optional, aber sehr empfehlenswert) + +data_imports → Log-Tabelle (wann welcher Ticker von welcher Quelle importiert wurde, Status, Fehler) +fundamental_data → Bilanz-/Gewinn- und Verlustdaten (kann auch als JSONB in instruments oder eigene Tabelle) +orders / trades → Deine automatisch platzierten IBKR-Orders (mit ibkr_conid, Order-ID, Status etc.) +watchlists / portfolios → Deine eigenen Watchlists mit eigenen Tags + +Praktische Umsetzungstipps + +Mapping-Prozess (wichtigster Teil deines Tools): +Neue externe Ticker kommen herein → erst /iserver/secdef/search?symbol=... oder /trsrv/stocks?symbols=... aufrufen. +Ergebnis in instruments speichern (falls neu) + Mapping in external_identifiers. +Danach immer nur noch über ibkr_conid arbeiten. + +Performance & Skalierung: +ibkr_conid + symbol + exchange stark indizieren. +Für sehr viele Instrumente (>> 10.000) lohnt sich eine separate instrument_aliases oder Cache-Layer (Redis). + +Flexibilität für weitere Datenquellen: +Immer data_source und external_identifiers benutzen → du bist komplett unabhängig von IBKR als einziger Datenquelle. + +Eigene Infos für Apple & Co.: +Einfach in custom_metadata oder instrument_custom_data schreiben. Die AI kann dort direkt Ergebnisse ablegen. + + +Diese Struktur ist praxisbewährt (ähnliche Schemata laufen bei vielen algo-Tradern mit IBKR + externen Daten). Sie ist gleichzeitig schlank und extrem erweiterbar. +Wenn du möchtest, kann ich dir: + +das komplette SQL-Skript (mit allen Indizes, Triggers, Timescale-Setup), +ein Python-Beispiel mit SQLAlchemy + IBKR-WebAPI-Client zum automatischen Mapping, +oder ein ER-Diagramm (Mermaid) liefern. + +Sag einfach Bescheid, wo du als Nächstes tiefer einsteigen willst! \ No newline at end of file diff --git a/settings.example.json b/settings.example.json new file mode 100644 index 0000000..3031bbc --- /dev/null +++ b/settings.example.json @@ -0,0 +1,29 @@ +{ + "Database": { + "Host": "your-db-host", + "Port": 3306, + "Database": "your-db-name", + "User": "your-db-user", + "Password": "CHANGE_ME" + }, + "IBKR": { + "Host": "127.0.0.1", + "Port": 4001, + "ClientId": 1 + }, + "Logging": { + "Level": "Info", + "MaxFileSizeMb": 10, + "RetentionDays": 30 + }, + "Webserver": { + "Port": 5001, + "Enabled": false + }, + "WorkerSettings": { + "BackupWorker": { + "Enabled": true, + "IntervalMinutes": 30 + } + } +}