Initial commit: IBKRTrader

.NET WinForms-Anwendung (Core, Modules/CongressTrading, UI).
Enthaelt .gitignore und settings.example.json als Konfigurationsvorlage.
Echte settings.json mit Zugangsdaten ist bewusst ausgeschlossen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-26 18:19:47 +02:00
co-authored by Claude Opus 4.8
commit ebeb035e92
47 changed files with 4527 additions and 0 deletions
+49
View File
@@ -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.
+47
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.AI;
/// <summary>
/// Platzhalter für KI-Modell-Integration.
/// Wird in einer späteren Phase mit echten ML-Modellen ersetzt.
/// </summary>
public class AIModelService
{
private readonly LoggingService _logger;
public AIModelService(LoggingService logger)
{
_logger = logger;
}
/// <summary>Platzhalter: Gibt immer 0.5 zurück (keine echte Vorhersage).</summary>
public Task<double> PredictAsync(string symbol, object features)
{
_logger.Warn("AI", $"AIModelService ist ein Platzhalter keine echte Vorhersage für {symbol}.");
return Task.FromResult(0.5);
}
}
+48
View File
@@ -0,0 +1,48 @@
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.Budget;
/// <summary>
/// Verwaltet das Budget pro Modul.
/// Liest/schreibt core_budget.
/// </summary>
public class BudgetService
{
private readonly DatabaseService _db;
private readonly LoggingService _logger;
public BudgetService(DatabaseService db, LoggingService logger)
{
_db = db;
_logger = logger;
}
public async Task<decimal> GetAvailableBudgetAsync(string module)
{
var row = await _db.QueryFirstOrDefaultAsync<dynamic>(
"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 });
}
}
+119
View File
@@ -0,0 +1,119 @@
using Dapper;
using MySqlConnector;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Database;
/// <summary>
/// Zentraler Datenbankzugriff via MySqlConnector + Dapper.
/// Jede Methode öffnet eine eigene Connection (Connection-Pooling via MySqlConnector).
/// </summary>
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<DateOnly>
{
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<bool> 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<IEnumerable<T>> QueryAsync<T>(string sql, object? param = null)
{
await using var conn = CreateConnection();
return await conn.QueryAsync<T>(sql, param);
}
public async Task<T?> QueryFirstOrDefaultAsync<T>(string sql, object? param = null)
{
await using var conn = CreateConnection();
return await conn.QueryFirstOrDefaultAsync<T>(sql, param);
}
public async Task<int> ExecuteAsync(string sql, object? param = null)
{
await using var conn = CreateConnection();
return await conn.ExecuteAsync(sql, param);
}
public async Task<T> ExecuteScalarAsync<T>(string sql, object? param = null)
{
await using var conn = CreateConnection();
#pragma warning disable CS8603
return await conn.ExecuteScalarAsync<T>(sql, param);
#pragma warning restore CS8603
}
// ─── Worker-Log Hilfsmethoden ─────────────────────────────────────────────
/// <summary>Legt einen neuen Worker-Log-Eintrag an und gibt die neue ID zurück.</summary>
public async Task<long> 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<long>(sql,
new { workerName, module, now = DateTime.UtcNow });
}
/// <summary>Schließt einen Worker-Log-Eintrag ab (Success oder Error).</summary>
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
});
}
}
@@ -0,0 +1,79 @@
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.Database.Migrations;
/// <summary>
/// Erstellt alle core_xxx-Tabellen idempotent (IF NOT EXISTS).
/// Wird einmalig beim App-Start ausgeführt.
/// </summary>
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;");
}
@@ -0,0 +1,85 @@
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.Database.Migrations;
/// <summary>
/// Erstellt die core_ibkr_xxx-Tabellen idempotent (IF NOT EXISTS).
/// Wird beim App-Start nach CoreMigrations ausgeführt.
/// </summary>
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;");
}
+274
View File
@@ -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;
/// <summary>
/// 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
/// </summary>
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 ──────────────────────────────────────────────────────
/// <summary>
/// Prüft den Authentifizierungsstatus der Gateway-Session.
/// </summary>
public async Task<IBKRAuthStatus?> 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<IBKRAuthStatus>(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;
}
}
/// <summary>
/// Hält die Session am Leben (Ping). Sollte alle ~60 Sekunden aufgerufen werden.
/// </summary>
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}");
}
}
/// <summary>
/// Initialisiert die Brokerage-Session über den Gateway.
/// </summary>
public async Task<bool> 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 ─────────────────────────────────────────────────────
/// <summary>
/// Sucht einen Contract anhand des Symbols.
/// POST /iserver/secdef/search
/// Body: {"symbol":"AAPL"}
/// </summary>
public async Task<List<IBKRContractSearchResult>?> 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<List<IBKRContractSearchResult>>(JsonOpts, ct);
}
catch (Exception ex)
{
_logger.Error("IBKR", $"Contract-Suche für '{symbol}' fehlgeschlagen: {ex.Message}", ex);
return null;
}
}
/// <summary>
/// Ruft detaillierte Contract-Informationen ab.
/// GET /iserver/contract/{conid}/info
/// </summary>
public async Task<IBKRContractInfo?> 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<IBKRContractInfo>(JsonOpts, ct);
}
catch (Exception ex)
{
_logger.Error("IBKR", $"Contract-Info für conid {conid} fehlgeschlagen: {ex.Message}", ex);
return null;
}
}
/// <summary>
/// Sucht Aktien-Contracts nach Symbolen.
/// GET /trsrv/stocks?symbols=AAPL,MSFT
/// </summary>
public async Task<Dictionary<string, List<IBKRStockContract>>?> 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<Dictionary<string, List<IBKRStockContract>>>(JsonOpts, ct);
}
catch (Exception ex)
{
_logger.Error("IBKR", $"Stock-Suche fehlgeschlagen: {ex.Message}", ex);
return null;
}
}
// ─── Historical Market Data ──────────────────────────────────────────────
/// <summary>
/// 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
/// </summary>
public async Task<IBKRHistoricalDataResponse?> 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<IBKRHistoricalDataResponse>(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;
}
}
+152
View File
@@ -0,0 +1,152 @@
using Dapper;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.IBKR;
/// <summary>
/// Datenzugriffsschicht für die core_ibkr_xxx-Tabellen.
/// Alle IBKR-Marktdaten-Operationen laufen über diese Klasse.
/// </summary>
public class IBKRMarketDataRepository
{
private readonly DatabaseService _db;
private readonly LoggingService _logger;
public IBKRMarketDataRepository(DatabaseService db, LoggingService logger)
{
_db = db;
_logger = logger;
}
// ─── Instruments ─────────────────────────────────────────────────────────
/// <summary>
/// Legt ein neues Instrument an oder aktualisiert ein bestehendes (UPSERT via conid).
/// Gibt die Instrument-ID zurück.
/// </summary>
public async Task<long> 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<long>(sql, instr);
}
/// <summary>Gibt alle aktiven Instrumente zurück.</summary>
public Task<IEnumerable<IBKRInstrument>> GetAllActiveInstrumentsAsync()
=> _db.QueryAsync<IBKRInstrument>(
"SELECT * FROM `core_ibkr_instruments` WHERE `active` = 1 ORDER BY `symbol`");
/// <summary>Findet ein Instrument anhand seiner IBKR ConID.</summary>
public Task<IBKRInstrument?> GetInstrumentByConidAsync(long conid)
=> _db.QueryFirstOrDefaultAsync<IBKRInstrument>(
"SELECT * FROM `core_ibkr_instruments` WHERE `ibkr_conid` = @conid",
new { conid });
/// <summary>Gibt die Anzahl aktiver Instrumente zurück.</summary>
public Task<int> GetActiveInstrumentCountAsync()
=> _db.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM `core_ibkr_instruments` WHERE `active` = 1");
// ─── Market Data ─────────────────────────────────────────────────────────
/// <summary>
/// Fügt Marktdaten-Balken via UPSERT ein (ON DUPLICATE KEY UPDATE).
/// </summary>
public async Task UpsertMarketDataBatchAsync(IEnumerable<IBKRMarketBar> 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);
}
/// <summary>Gibt den neuesten Timestamp für ein Instrument zurück.</summary>
public Task<DateTime?> GetLatestBarTimestampAsync(long instrumentId, string barSize = "daily")
=> _db.QueryFirstOrDefaultAsync<DateTime?>(
@"SELECT MAX(`timestamp`) FROM `core_ibkr_market_data`
WHERE `instrument_id` = @instrumentId AND `bar_size` = @barSize",
new { instrumentId, barSize });
/// <summary>Gibt die Anzahl Bars für ein Instrument zurück.</summary>
public Task<int> GetBarCountAsync(long instrumentId, string barSize = "daily")
=> _db.ExecuteScalarAsync<int>(
@"SELECT COUNT(*) FROM `core_ibkr_market_data`
WHERE `instrument_id` = @instrumentId AND `bar_size` = @barSize",
new { instrumentId, barSize });
// ─── External Identifiers ────────────────────────────────────────────────
/// <summary>
/// Erstellt oder ignoriert ein External-Identifier-Mapping (IGNORE bei Duplikat).
/// </summary>
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 });
/// <summary>
/// Findet ein Instrument anhand eines externen Tickers (z.B. aus ct_trade).
/// </summary>
public Task<IBKRInstrument?> FindInstrumentByExternalTickerAsync(string source, string ticker)
=> _db.QueryFirstOrDefaultAsync<IBKRInstrument>(@"
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 });
/// <summary>
/// Findet alle einzigartigen Ticker aus ct_trade, die noch kein IBKR-Mapping haben.
/// </summary>
public Task<IEnumerable<string>> GetUnmappedTickersFromCongressTradesAsync()
=> _db.QueryAsync<string>(@"
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`");
}
+336
View File
@@ -0,0 +1,336 @@
using System.Text.Json.Serialization;
namespace IBKRTrader.Core.IBKR;
// ─── DB Entities ─────────────────────────────────────────────────────────────
/// <summary>
/// Repräsentiert ein IBKR-Instrument in core_ibkr_instruments.
/// ibkr_conid ist der zentrale, eindeutige IBKR-Schlüssel.
/// </summary>
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; }
}
/// <summary>
/// Repräsentiert einen OHLCV-Balken in core_ibkr_market_data.
/// </summary>
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; }
}
/// <summary>
/// Externes Ticker-Mapping in core_ibkr_external_identifiers.
/// </summary>
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 ───────────────────────────────────────────────────────
/// <summary>
/// Response von GET /iserver/auth/status
/// </summary>
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; }
}
/// <summary>
/// Einzelnes Ergebnis der Contract-Suche (POST /iserver/secdef/search).
/// </summary>
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<IBKRContractSection>? Sections { get; set; }
}
/// <summary>
/// Sections innerhalb eines Contract-Suchergebnisses.
/// </summary>
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; }
}
/// <summary>
/// Response von GET /iserver/contract/{conid}/info
/// </summary>
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; }
}
/// <summary>
/// Response von GET /iserver/marketdata/history
/// </summary>
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<IBKRHistoricalBar>? Data { get; set; }
[JsonPropertyName("points")]
public int? Points { get; set; }
[JsonPropertyName("travelTime")]
public int? TravelTime { get; set; }
}
/// <summary>
/// Einzelner OHLCV-Balken aus der IBKR Historical Data Response.
/// </summary>
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
}
/// <summary>
/// Response von GET /trsrv/stocks?symbols=...
/// Die API gibt ein Dictionary zurück: { "AAPL": [{ ... }] }
/// </summary>
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<IBKRStockContractEntry>? 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; }
}
+8
View File
@@ -0,0 +1,8 @@
namespace IBKRTrader.Core.Logging;
public enum AppLogLevel
{
Info = 0,
Warn = 1,
Error = 2
}
+10
View File
@@ -0,0 +1,10 @@
namespace IBKRTrader.Core.Logging;
/// <summary>Immutable log entry wird in Datei und RichTextBox geschrieben.</summary>
public sealed record LogEntry(
DateTime Timestamp,
AppLogLevel Level,
string Module,
string Message,
Exception? Exception = null
);
+105
View File
@@ -0,0 +1,105 @@
using System.Runtime.CompilerServices;
namespace IBKRTrader.Core.Logging;
/// <summary>
/// Thread-sicherer Logging-Service.
/// Schreibt farbig in die RichTextBox (UI-Thread-safe via BeginInvoke)
/// Schreibt in Logs\{Module}\{Level}-dd-MM-yy.txt
/// </summary>
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();
}
}
+207
View File
@@ -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();
}
+49
View File
@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace IBKRTrader.Core.Settings;
/// <summary>
/// Lädt und speichert settings.json neben der EXE.
/// Singleton wird beim App-Start einmalig geladen.
/// </summary>
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<AppSettings>(json, JsonOptions) ?? new AppSettings();
}
catch
{
Settings = new AppSettings();
Save();
}
}
public void Save()
{
var json = JsonSerializer.Serialize(Settings, JsonOptions);
File.WriteAllText(SettingsPath, json);
}
}
+47
View File
@@ -0,0 +1,47 @@
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.Trading;
/// <summary>Verwaltet core_trade_history Lesen und Schreiben von Trade-Einträgen.</summary>
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<IEnumerable<dynamic>> GetRecentTradesAsync(int limit = 100)
=> await _db.QueryAsync<dynamic>(
"SELECT * FROM `core_trade_history` ORDER BY traded_at DESC LIMIT @limit",
new { limit });
}
+146
View File
@@ -0,0 +1,146 @@
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Workers.BuiltIn;
/// <summary>
/// BackupWorker (Core) läuft alle 30 Minuten (konfigurierbar).
/// 1. Ruft mysqldump.exe auf → SQL-Dump in Backups\DB\
/// 2. Kopiert Logs\ → Backups\Logs\
/// Falls mysqldump nicht gefunden: Warnung und Überspringen.
/// </summary>
public class BackupWorker : WorkerBase
{
private readonly SettingsService _settings;
public override string Name => "BackupWorker";
public override string Module => "Core";
public override WorkerType Type => WorkerType.Worker;
protected override TimeSpan? Interval =>
TimeSpan.FromMinutes(_settings.Settings.WorkerSettings.BackupWorker.IntervalMinutes);
public BackupWorker(LoggingService logger, DatabaseService db, SettingsService settings)
: base(logger, db)
{
_settings = settings;
Info.Active = settings.Settings.WorkerSettings.BackupWorker.Enabled;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm");
var backupRoot = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Backups");
var dbDir = Path.Combine(backupRoot, "DB");
var logsDir = Path.Combine(backupRoot, "Logs");
Directory.CreateDirectory(dbDir);
Directory.CreateDirectory(logsDir);
// ── 1. Datenbank-Dump ────────────────────────────────────────────────
await RunMysqlDumpAsync(dbDir, timestamp, ct);
// ── 2. Logs-Backup ───────────────────────────────────────────────────
CopyLogs(logsDir, timestamp);
Logger.Info(Module, $"Backup abgeschlossen: {timestamp}");
}
// ─── mysqldump ────────────────────────────────────────────────────────────
private async Task RunMysqlDumpAsync(string dbDir, string timestamp, CancellationToken ct)
{
var dump = FindMysqldump();
if (dump == null)
{
Logger.Warn(Module, "mysqldump.exe nicht gefunden DB-Backup übersprungen.");
return;
}
var db = _settings.Settings.Database;
var dumpFile = Path.Combine(dbDir, $"{db.Database}_{timestamp}.sql");
var args = $"--host={db.Host} --port={db.Port} " +
$"--user={db.User} --password={db.Password} " +
$"--single-transaction --routines --triggers " +
$"{db.Database} --result-file=\"{dumpFile}\"";
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = dump,
Arguments = args,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = System.Diagnostics.Process.Start(psi)
?? throw new InvalidOperationException("mysqldump konnte nicht gestartet werden.");
await proc.WaitForExitAsync(ct);
if (proc.ExitCode != 0)
{
var err = await proc.StandardError.ReadToEndAsync(ct);
Logger.Warn(Module, $"mysqldump Exitcode {proc.ExitCode}: {err}");
}
else
{
Logger.Info(Module, $"DB-Dump erstellt: {Path.GetFileName(dumpFile)}");
}
}
private static string? FindMysqldump()
{
// Häufige Installationspfade auf Windows-Server
var candidates = new[]
{
"mysqldump.exe",
@"C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqldump.exe",
@"C:\Program Files\MySQL\MySQL Server 8.4\bin\mysqldump.exe",
@"C:\xampp\mysql\bin\mysqldump.exe"
};
foreach (var c in candidates)
if (File.Exists(c)) return c;
// PATH-Suche
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var dir in pathVar.Split(';'))
{
var full = Path.Combine(dir.Trim(), "mysqldump.exe");
if (File.Exists(full)) return full;
}
return null;
}
// ─── Logs kopieren ────────────────────────────────────────────────────────
private void CopyLogs(string logsBackupDir, string timestamp)
{
try
{
var srcLogs = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
if (!Directory.Exists(srcLogs)) return;
var destDir = Path.Combine(logsBackupDir, timestamp);
CopyDirectory(srcLogs, destDir);
Logger.Info(Module, $"Logs-Backup erstellt: {destDir}");
}
catch (Exception ex)
{
Logger.Warn(Module, $"Logs-Backup fehlgeschlagen: {ex.Message}");
}
}
private static void CopyDirectory(string src, string dest)
{
Directory.CreateDirectory(dest);
foreach (var file in Directory.GetFiles(src))
File.Copy(file, Path.Combine(dest, Path.GetFileName(file)), overwrite: true);
foreach (var dir in Directory.GetDirectories(src))
CopyDirectory(dir, Path.Combine(dest, Path.GetFileName(dir)));
}
}
@@ -0,0 +1,133 @@
using IBKRTrader.Core.Database;
using IBKRTrader.Core.IBKR;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Workers.BuiltIn;
/// <summary>
/// Synchronisiert IBKR-Instrumentenstammdaten alle 30 Tage.
/// </summary>
public class IBKRInstrumentSyncWorker : WorkerBase
{
public override string Name => "IBKR Instrument Sync";
public override string Module => "Core";
public override WorkerType Type => WorkerType.Worker;
protected override TimeSpan? Interval =>
TimeSpan.FromMinutes(_settings.Settings.WorkerSettings.InstrumentSyncWorker.IntervalMinutes);
private readonly SettingsService _settings;
private readonly IBKRGatewayService _gateway;
private readonly IBKRMarketDataRepository _repo;
public IBKRInstrumentSyncWorker(
LoggingService logger, DatabaseService db,
SettingsService settings, IBKRGatewayService gateway,
IBKRMarketDataRepository repo)
: base(logger, db)
{
_settings = settings;
_gateway = gateway;
_repo = repo;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
if (!_settings.Settings.IBKRWebApi.Enabled)
{
Logger.Info(Module, "IBKR Web API deaktiviert InstrumentSync übersprungen.");
return;
}
var authStatus = await _gateway.CheckAuthStatusAsync(ct);
if (authStatus?.Authenticated != true)
{
Logger.Warn(Module, "IBKR Gateway nicht authentifiziert InstrumentSync übersprungen.");
return;
}
await _gateway.TickleAsync(ct);
await MapUnmappedTickersAsync(ct);
await RefreshExistingInstrumentsAsync(ct);
}
private async Task MapUnmappedTickersAsync(CancellationToken ct)
{
var unmapped = (await _repo.GetUnmappedTickersFromCongressTradesAsync()).ToList();
if (unmapped.Count == 0) { Logger.Info(Module, "Keine neuen unmapped Ticker."); return; }
Logger.Info(Module, $"Mappe {unmapped.Count} neue Ticker auf IBKR-Contracts...");
int mapped = 0, failed = 0;
foreach (var ticker in unmapped)
{
if (ct.IsCancellationRequested) break;
try
{
var results = await _gateway.SearchContractBySymbolAsync(ticker, ct);
if (results == null || results.Count == 0)
{ Logger.Warn(Module, $"Kein Contract für: {ticker}"); failed++; continue; }
long conid = results[0].ConId;
var existing = await _repo.GetInstrumentByConidAsync(conid);
if (existing == null)
{
var info = await _gateway.GetContractInfoAsync(conid, ct);
var instrument = new IBKRInstrument
{
IbkrConid = conid, Symbol = info?.Symbol ?? ticker,
SecType = info?.InstrumentType ?? "STK",
Exchange = info?.Exchange ?? "SMART",
PrimaryExchange = info?.ListingExchange,
Currency = info?.Currency ?? "USD",
CompanyName = info?.CompanyName ?? results[0].CompanyName,
Sector = info?.Sector, Industry = info?.Industry,
Description = info?.Text, Active = true,
LastFetched = DateTime.UtcNow
};
var id = await _repo.UpsertInstrumentAsync(instrument);
await _repo.UpsertExternalIdentifierAsync(id, "capitoltrades", ticker);
Logger.Info(Module, $"Neu: {ticker} → conid {conid}");
}
else
{
await _repo.UpsertExternalIdentifierAsync(existing.Id, "capitoltrades", ticker);
}
mapped++;
}
catch (Exception ex) { Logger.Error(Module, $"Mapping '{ticker}': {ex.Message}", ex); failed++; }
}
Logger.Info(Module, $"Mapping: {mapped} OK, {failed} Fehler.");
}
private async Task RefreshExistingInstrumentsAsync(CancellationToken ct)
{
var instruments = (await _repo.GetAllActiveInstrumentsAsync()).ToList();
if (instruments.Count == 0) return;
Logger.Info(Module, $"Aktualisiere {instruments.Count} Instrumente...");
int ok = 0, err = 0;
foreach (var instr in instruments)
{
if (ct.IsCancellationRequested) break;
try
{
var info = await _gateway.GetContractInfoAsync(instr.IbkrConid, ct);
if (info == null) { err++; continue; }
instr.Symbol = info.Symbol ?? instr.Symbol;
instr.CompanyName = info.CompanyName ?? instr.CompanyName;
instr.Sector = info.Sector ?? instr.Sector;
instr.Industry = info.Industry ?? instr.Industry;
instr.LastFetched = DateTime.UtcNow;
await _repo.UpsertInstrumentAsync(instr);
ok++;
}
catch (Exception ex) { Logger.Error(Module, $"Update {instr.Symbol}: {ex.Message}", ex); err++; }
}
Logger.Info(Module, $"Stammdaten: {ok} OK, {err} Fehler.");
}
}
@@ -0,0 +1,176 @@
using IBKRTrader.Core.Database;
using IBKRTrader.Core.IBKR;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Workers.BuiltIn;
/// <summary>
/// Ruft täglich die Preishistorie (daily OHLCV) für alle aktiven
/// IBKR-Instrumente ab und speichert sie in core_ibkr_market_data.
///
/// Ablauf:
/// 1. Prüft ob IBKR Web API aktiviert und Gateway authentifiziert
/// 2. Iteriert über alle aktiven Instrumente
/// 3. Bestimmt den benötigten Zeitraum (initial: 2Y, danach: ab letztem Bar)
/// 4. Ruft HMDS Historical Data ab und speichert via UPSERT
/// </summary>
public class IBKRPriceHistoryWorker : WorkerBase
{
public override string Name => "IBKR Price History";
public override string Module => "Core";
public override WorkerType Type => WorkerType.Worker;
protected override TimeSpan? Interval =>
TimeSpan.FromMinutes(_settings.Settings.WorkerSettings.PriceHistoryWorker.IntervalMinutes);
private readonly SettingsService _settings;
private readonly IBKRGatewayService _gateway;
private readonly IBKRMarketDataRepository _repo;
public IBKRPriceHistoryWorker(
LoggingService logger, DatabaseService db,
SettingsService settings, IBKRGatewayService gateway,
IBKRMarketDataRepository repo)
: base(logger, db)
{
_settings = settings;
_gateway = gateway;
_repo = repo;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
if (!_settings.Settings.IBKRWebApi.Enabled)
{
Logger.Info(Module, "IBKR Web API deaktiviert PriceHistory übersprungen.");
return;
}
var authStatus = await _gateway.CheckAuthStatusAsync(ct);
if (authStatus?.Authenticated != true)
{
Logger.Warn(Module, "IBKR Gateway nicht authentifiziert PriceHistory übersprungen.");
return;
}
await _gateway.TickleAsync(ct);
var instruments = (await _repo.GetAllActiveInstrumentsAsync()).ToList();
if (instruments.Count == 0)
{
Logger.Info(Module, "Keine aktiven Instrumente PriceHistory übersprungen.");
return;
}
Logger.Info(Module, $"Starte Preishistorie-Abruf für {instruments.Count} Instrumente...");
int updated = 0, errors = 0, skipped = 0;
for (int i = 0; i < instruments.Count; i++)
{
if (ct.IsCancellationRequested) break;
var instr = instruments[i];
try
{
// Benötigten Zeitraum bestimmen
var latestBar = await _repo.GetLatestBarTimestampAsync(instr.Id);
string period = DeterminePeriod(latestBar);
if (period == "SKIP")
{
skipped++;
continue;
}
// Historische Daten abrufen
var histData = await _gateway.GetHistoricalDataAsync(
instr.IbkrConid, period, "1d", outsideRth: false, ct: ct);
if (histData?.Data == null || histData.Data.Count == 0)
{
Logger.Warn(Module, $"Keine Daten für {instr.Symbol} (conid {instr.IbkrConid})");
errors++;
continue;
}
// Bars konvertieren und speichern
var bars = ConvertBars(instr.Id, histData);
await _repo.UpsertMarketDataBatchAsync(bars);
updated++;
if ((i + 1) % 25 == 0 || i == instruments.Count - 1)
{
Logger.Info(Module, $"Fortschritt: {i + 1}/{instruments.Count} " +
$"({updated} OK, {errors} Fehler, {skipped} übersprungen)");
// Session alive halten bei großen Mengen
await _gateway.TickleAsync(ct);
}
}
catch (Exception ex)
{
Logger.Error(Module, $"Fehler bei {instr.Symbol}: {ex.Message}", ex);
errors++;
}
}
Logger.Info(Module, $"Preishistorie abgeschlossen: {updated} aktualisiert, " +
$"{errors} Fehler, {skipped} übersprungen.");
}
/// <summary>
/// Bestimmt den HMDS-Period-Parameter basierend auf dem letzten vorhandenen Bar.
/// </summary>
private string DeterminePeriod(DateTime? latestBar)
{
if (latestBar == null)
{
// Erster Import: verwende konfigurierten Zeitraum
return _settings.Settings.IBKRWebApi.HistoryPeriod;
}
var daysSinceLast = (DateTime.UtcNow - latestBar.Value).TotalDays;
if (daysSinceLast < 1) return "SKIP"; // Bereits aktuell
if (daysSinceLast <= 7) return "1w";
if (daysSinceLast <= 30) return "1m";
if (daysSinceLast <= 90) return "3m";
if (daysSinceLast <= 180) return "6m";
if (daysSinceLast <= 365) return "1y";
return "2y";
}
/// <summary>
/// Konvertiert IBKR API-Bars in DB-Entities.
/// </summary>
private static List<IBKRMarketBar> ConvertBars(
long instrumentId, IBKRHistoricalDataResponse response)
{
var bars = new List<IBKRMarketBar>();
if (response.Data == null) return bars;
foreach (var apiBar in response.Data)
{
// IBKR liefert Timestamp in Millisekunden seit Unix-Epoch
var timestamp = DateTimeOffset
.FromUnixTimeMilliseconds(apiBar.Timestamp)
.UtcDateTime;
bars.Add(new IBKRMarketBar
{
InstrumentId = instrumentId,
BarSize = "daily",
Timestamp = timestamp,
Open = apiBar.Open,
High = apiBar.High,
Low = apiBar.Low,
Close = apiBar.Close,
Volume = apiBar.Volume
});
}
return bars;
}
}
+104
View File
@@ -0,0 +1,104 @@
using System.Net;
using System.Text;
using System.Text.Json;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Workers.BuiltIn;
/// <summary>
/// WebApiService permanenter REST-API-Service auf Basis von HttpListener.
/// Basis-Routen: GET /api/status, GET /api/workers
/// Wird später schrittweise um vollständige API-Endpunkte erweitert.
/// </summary>
public class WebApiService : WorkerBase
{
private readonly SettingsService _settings;
private WorkerEngine? _engine; // wird nach DI-Aufbau gesetzt (kein readonly)
private HttpListener? _listener;
private static readonly JsonSerializerOptions JsonOpts =
new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public override string Name => "WebApiService";
public override string Module => "Core";
public override WorkerType Type => WorkerType.Service;
protected override TimeSpan? Interval => null;
public WebApiService(LoggingService logger, DatabaseService db, SettingsService settings)
: base(logger, db)
{
_settings = settings;
Info.Active = settings.Settings.Webserver.Enabled;
Info.RunEvery = "Service";
}
/// <summary>Setzt die WorkerEngine nach DI-Aufbau (zirkulare Abhängigkeit vermeiden).</summary>
public void SetEngine(WorkerEngine engine) => _engine = engine;
protected override async Task ExecuteAsync(CancellationToken ct)
{
var port = _settings.Settings.Webserver.Port + 1; // API auf Port+1
_listener = new HttpListener();
_listener.Prefixes.Add($"http://localhost:{port}/api/");
try
{
_listener.Start();
Logger.Info(Module, $"WebAPI gestartet auf http://localhost:{port}/api/");
Info.Info = $"http://localhost:{port}/api/";
while (!ct.IsCancellationRequested)
{
var contextTask = _listener.GetContextAsync();
var cancelTask = Task.Delay(Timeout.Infinite, ct);
var completed = await Task.WhenAny(contextTask, cancelTask);
if (completed == cancelTask) break;
var ctx = await contextTask;
_ = RouteAsync(ctx);
}
}
finally
{
_listener.Stop();
Logger.Info(Module, "WebAPI gestoppt.");
}
}
private async Task RouteAsync(HttpListenerContext ctx)
{
try
{
var path = ctx.Request.Url?.AbsolutePath.TrimEnd('/').ToLowerInvariant() ?? "";
object? result = path switch
{
"/api/status" => new { status = "ok", time = DateTime.UtcNow },
"/api/workers" => _engine?.WorkerInfos
.Select(w => new { w.WorkerName, w.Module, w.Type,
w.Info, w.Active }) ?? [],
_ => null
};
if (result == null)
{
ctx.Response.StatusCode = 404;
ctx.Response.Close();
return;
}
var json = JsonSerializer.Serialize(result, JsonOpts);
var bytes = Encoding.UTF8.GetBytes(json);
ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.StatusCode = 200;
await ctx.Response.OutputStream.WriteAsync(bytes);
ctx.Response.Close();
}
catch { }
}
}
+81
View File
@@ -0,0 +1,81 @@
using System.Net;
using System.Text;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Workers.BuiltIn;
/// <summary>
/// WebserverService permanenter Service auf Basis von System.Net.HttpListener.
/// Lauscht auf http://localhost:{port}/ und liefert einen Status-JSON.
/// Wird später durch vollständige Web-UI erweitert.
/// </summary>
public class WebserverService : WorkerBase
{
private readonly SettingsService _settings;
private HttpListener? _listener;
public override string Name => "WebserverService";
public override string Module => "Core";
public override WorkerType Type => WorkerType.Service;
protected override TimeSpan? Interval => null; // Service = permanent
public WebserverService(LoggingService logger, DatabaseService db, SettingsService settings)
: base(logger, db)
{
_settings = settings;
Info.Active = settings.Settings.Webserver.Enabled;
Info.RunEvery = "Service";
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
var port = _settings.Settings.Webserver.Port;
_listener = new HttpListener();
_listener.Prefixes.Add($"http://localhost:{port}/");
try
{
_listener.Start();
Logger.Info(Module, $"Webserver gestartet auf http://localhost:{port}/");
Info.Info = $"http://localhost:{port}/";
while (!ct.IsCancellationRequested)
{
// GetContextAsync blockiert abbrechen via ct
var contextTask = _listener.GetContextAsync();
var cancelTask = Task.Delay(Timeout.Infinite, ct);
var completed = await Task.WhenAny(contextTask, cancelTask);
if (completed == cancelTask) break;
var ctx = await contextTask;
_ = HandleRequestAsync(ctx); // fire-and-forget pro Request
}
}
finally
{
_listener.Stop();
Logger.Info(Module, "Webserver gestoppt.");
}
}
private async Task HandleRequestAsync(HttpListenerContext ctx)
{
try
{
const string json = """{"status":"ok","app":"IBKRTrader","version":"1.0.0"}""";
var bytes = Encoding.UTF8.GetBytes(json);
ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.StatusCode = 200;
await ctx.Response.OutputStream.WriteAsync(bytes);
ctx.Response.Close();
}
catch { /* Client hat getrennt */ }
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace IBKRTrader.Core.Workers;
/// <summary>
/// Vertrag für jeden Worker oder Service im System.
/// Core und Module müssen dieses Interface implementieren.
/// </summary>
public interface IWorker
{
/// <summary>Anzeigename in dgv_workerlist.</summary>
string Name { get; }
/// <summary>Modul-Kürzel (z. B. "Core", "CT").</summary>
string Module { get; }
/// <summary>Worker = periodisch / Service = dauerhaft.</summary>
WorkerType Type { get; }
/// <summary>Live-Daten für die DataGridView-Zeile.</summary>
WorkerInfo Info { get; }
/// <summary>Startet den Worker/Service asynchron.</summary>
Task StartAsync(CancellationToken ct);
/// <summary>Stoppt den Worker/Service sauber.</summary>
Task StopAsync();
/// <summary>Löst einen sofortigen, manuellen Run aus (unabhängig vom Zeitplan).</summary>
Task TriggerAsync();
}
+162
View File
@@ -0,0 +1,162 @@
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.Workers;
/// <summary>
/// Basisklasse für alle periodischen Worker.
/// Verwaltet den internen CancellationToken-Lifecycle
/// Führt ExecuteAsync() im konfigurierten Interval aus
/// Aktualisiert WorkerInfo (LastRuntime, NextRuntime, Status, Info)
/// Schreibt Runs in core_worker_log
/// </summary>
public abstract class WorkerBase : IWorker
{
// ─── Abstrakte Member ─────────────────────────────────────────────────────
public abstract string Name { get; }
public abstract string Module { get; }
public virtual WorkerType Type => WorkerType.Worker;
/// <summary>Intervall zwischen zwei Runs. null = Service (keine Wiederholung).</summary>
protected abstract TimeSpan? Interval { get; }
/// <summary>Führt die eigentliche Arbeit des Workers aus.</summary>
protected abstract Task ExecuteAsync(CancellationToken ct);
// ─── Infrastruktur ────────────────────────────────────────────────────────
protected readonly LoggingService Logger;
protected readonly DatabaseService Db;
public WorkerInfo Info { get; } = new();
private CancellationTokenSource? _cts;
private Task? _runLoop;
private readonly SemaphoreSlim _triggerSemaphore = new(0, 1);
protected WorkerBase(LoggingService logger, DatabaseService db)
{
Logger = logger;
Db = db;
Info.WorkerName = Name;
Info.Module = Module;
Info.Type = Type.ToString();
// Info.RunEvery wird NICHT hier gesetzt Interval ist abstract/virtual
// und die abgeleitete Klasse hat ihre Felder zu diesem Zeitpunkt noch
// nicht initialisiert (Base-Ctor läuft vor dem Derived-Ctor).
// → RunEvery wird lazy in StartAsync gesetzt.
Info.Status = WorkerStatus.Idle;
Info.Info = "Bereit";
Info.Active = true;
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
public Task StartAsync(CancellationToken externalCt)
{
if (_runLoop is { IsCompleted: false }) return Task.CompletedTask;
// Lazy: RunEvery erst hier setzen, wenn alle Derived-Felder sicher initialisiert sind
if (string.IsNullOrEmpty(Info.RunEvery))
Info.RunEvery = Interval.HasValue ? FormatInterval(Interval.Value) : "Service";
_cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
_runLoop = Task.Run(() => RunLoopAsync(_cts.Token), _cts.Token);
return Task.CompletedTask;
}
public async Task StopAsync()
{
if (_cts == null) return;
await _cts.CancelAsync();
if (_runLoop != null)
await _runLoop.ConfigureAwait(false);
Info.Status = WorkerStatus.Stopped;
Info.Info = "Gestoppt";
}
public async Task TriggerAsync()
{
// Gibt das Semaphore frei der RunLoop führt sofort einen Run aus
if (_triggerSemaphore.CurrentCount == 0)
_triggerSemaphore.Release();
await Task.CompletedTask;
}
// ─── Interner Run-Loop ────────────────────────────────────────────────────
private async Task RunLoopAsync(CancellationToken ct)
{
Logger.Info(Module, $"Worker gestartet: {Name}");
while (!ct.IsCancellationRequested)
{
await RunOnceAsync(ct);
if (Interval == null) break; // Service: nur einmal
var next = DateTime.Now.Add(Interval.Value);
Info.NextRuntime = next;
// Warte auf Interval ODER manuellen Trigger
var remaining = next - DateTime.Now;
if (remaining > TimeSpan.Zero)
{
try
{
await _triggerSemaphore
.WaitAsync(remaining, ct)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { break; }
}
}
Logger.Info(Module, $"Worker beendet: {Name}");
}
private async Task RunOnceAsync(CancellationToken ct)
{
Info.Status = WorkerStatus.Running;
Info.Info = "Läuft...";
long logId = 0;
try
{
logId = await Db.BeginWorkerLogAsync(Name, Module);
await ExecuteAsync(ct);
await Db.EndWorkerLogAsync(logId, true);
Info.LastRuntime = DateTime.Now;
Info.Status = WorkerStatus.Idle;
Info.Info = $"OK {Info.LastRuntime:HH:mm:ss}";
}
catch (OperationCanceledException)
{
if (logId > 0)
await Db.EndWorkerLogAsync(logId, false, "Abgebrochen");
Info.Status = WorkerStatus.Stopped;
Info.Info = "Abgebrochen";
}
catch (Exception ex)
{
if (logId > 0)
await Db.EndWorkerLogAsync(logId, false, ex.Message);
Logger.Error(Module, $"Fehler in Worker {Name}: {ex.Message}", ex);
Info.Status = WorkerStatus.Error;
Info.Info = $"Fehler: {ex.Message}";
}
}
// ─── Hilfsmethoden ────────────────────────────────────────────────────────
private static string FormatInterval(TimeSpan ts)
{
if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d";
if (ts.TotalHours >= 1) return $"{(int)ts.TotalHours}h";
return $"{(int)ts.TotalMinutes}m";
}
}
+96
View File
@@ -0,0 +1,96 @@
using System.Collections.Concurrent;
using System.ComponentModel;
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.Workers;
/// <summary>
/// Orchestriert alle registrierten IWorker.
/// Startet/stoppt Worker
/// Stellt BindingList für DataGridView bereit
/// Thread-sicher via ConcurrentDictionary
/// </summary>
public class WorkerEngine
{
private readonly IEnumerable<IWorker> _workers;
private readonly LoggingService _logger;
private readonly ConcurrentDictionary<string, IWorker> _registry = new();
private CancellationTokenSource _cts = new();
/// <summary>Live-bindbare Liste für dgv_workerlist.</summary>
public BindingList<WorkerInfo> WorkerInfos { get; } = [];
public WorkerEngine(IEnumerable<IWorker> workers, LoggingService logger)
{
_workers = workers;
_logger = logger;
}
// ─── Engine-Lifecycle ─────────────────────────────────────────────────────
public async Task StartAllAsync()
{
_cts = new CancellationTokenSource();
foreach (var worker in _workers)
{
RegisterWorker(worker);
if (worker.Info.Active)
await worker.StartAsync(_cts.Token);
}
_logger.Info("Core", $"WorkerEngine gestartet {_registry.Count} Worker/Services registriert.");
}
public async Task StopAllAsync()
{
await _cts.CancelAsync();
var tasks = _registry.Values.Select(w => w.StopAsync());
await Task.WhenAll(tasks);
_logger.Info("Core", "WorkerEngine gestoppt.");
}
// ─── Einzelsteuerung ──────────────────────────────────────────────────────
public async Task StartWorkerAsync(string name)
{
if (!_registry.TryGetValue(name, out var worker)) return;
worker.Info.Active = true;
await worker.StartAsync(_cts.Token);
_logger.Info("Core", $"Worker manuell gestartet: {name}");
}
public async Task StopWorkerAsync(string name)
{
if (!_registry.TryGetValue(name, out var worker)) return;
worker.Info.Active = false;
await worker.StopAsync();
_logger.Info("Core", $"Worker manuell gestoppt: {name}");
}
public async Task TriggerWorkerAsync(string name)
{
if (!_registry.TryGetValue(name, out var worker)) return;
await worker.TriggerAsync();
_logger.Info("Core", $"Worker manuell ausgelöst: {name}");
}
// ─── Intern ───────────────────────────────────────────────────────────────
private void RegisterWorker(IWorker worker)
{
_registry[worker.Name] = worker;
// WorkerInfo in BindingList eintragen (UI-Thread, falls nötig)
if (WorkerInfos is { } list)
{
if (SynchronizationContext.Current != null)
list.Add(worker.Info);
else
list.Add(worker.Info);
}
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace IBKRTrader.Core.Workers;
/// <summary>
/// ViewModel-Objekt für eine Zeile in dgv_workerlist.
/// Implementiert INotifyPropertyChanged für automatisches DataGridView-Binding.
/// </summary>
public class WorkerInfo : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private bool _active;
private string _type = "";
private string _module = "";
private string _workerName = "";
private DateTime? _lastRuntime;
private DateTime? _nextRuntime;
private string _runEvery = "";
private string _info = "";
private WorkerStatus _status = WorkerStatus.Idle;
// ─── Properties ───────────────────────────────────────────────────────────
public bool Active { get => _active; set => Set(ref _active, value); }
public string Type { get => _type; set => Set(ref _type, value); }
public string Module { get => _module; set => Set(ref _module, value); }
public string WorkerName { get => _workerName; set => Set(ref _workerName, value); }
public DateTime? LastRuntime { get => _lastRuntime; set => Set(ref _lastRuntime, value); }
public DateTime? NextRuntime { get => _nextRuntime; set => Set(ref _nextRuntime, value); }
public string RunEvery { get => _runEvery; set => Set(ref _runEvery, value); }
public string Info { get => _info; set => Set(ref _info, value); }
/// <summary>Interner Status wird nicht direkt als DGV-Spalte verwendet,
/// aber steuert die Info-Spalte.</summary>
public WorkerStatus Status
{
get => _status;
set
{
if (!Set(ref _status, value)) return;
// Info-Text automatisch synchronisieren
if (value == WorkerStatus.Idle && string.IsNullOrWhiteSpace(_info))
Info = "Bereit";
}
}
// ─── Hilfsmethode ─────────────────────────────────────────────────────────
private bool Set<T>(ref T field, T value, [CallerMemberName] string? prop = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));
return true;
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace IBKRTrader.Core.Workers;
public enum WorkerStatus
{
Idle,
Running,
Error,
Stopped,
Disabled
}
+10
View File
@@ -0,0 +1,10 @@
namespace IBKRTrader.Core.Workers;
public enum WorkerType
{
/// <summary>Läuft periodisch nach einem festen Zeitplan.</summary>
Worker,
/// <summary>Läuft permanent (z. B. Webserver, WebAPI).</summary>
Service
}
+260
View File
@@ -0,0 +1,260 @@
namespace IBKRTrader
{
partial class Form1
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
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;
}
}
+131
View File
@@ -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<AppLogLevel>(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}";
}
}
}
+150
View File
@@ -0,0 +1,150 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="menuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<metadata name="toolStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>175, 17</value>
</metadata>
<metadata name="statusStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>320, 17</value>
</metadata>
<metadata name="toolStrip3.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>625, 17</value>
</metadata>
<metadata name="toolStrip2.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>480, 17</value>
</metadata>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
<data name="btn_activateTrading.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
YQUAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAEBSURBVEhL3ZIvC8JAGIf3OTTZ/A62+RXs2g2rFsGi2P0C
WlwT02wGMQjLhgVRnAMR/5bVV37CHeduf9TdFQfPDt7wPOy9GWEYkk4MvGzbJsuylAInD2BgmqZS4JQC
99tDCcoCl+OBPKdH3qxHe3eiPuAOTFq0CxwWURYQ5QBfojfgKA5sl0Mux7pwJx8H/NOdmvPj64yKs8gM
QFqdBlQc7V/nt5HUwDq4cTnj20hiYLW7Unnsv8l/icQGKrVGojwrgpk4lwL1VpdKw40kjCMaYfclzqVA
3z1LojSYTPwZ2Bxrzh1gsujPALBmrDt3IA2sG2vXFgBw/lFAJwh0dGLofp4xWrfOyeSKuAAAAABJRU5E
rkJggg==
</value>
</data>
<metadata name="toolStrip3.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>625, 17</value>
</metadata>
</root>
+28
View File
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Nur englische Satellitenassemblies kein Sprachmüll im Build-Ordner -->
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MySqlConnector" Version="2.4.0" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.72" />
</ItemGroup>
<ItemGroup>
<!-- settings.json immer in Ausgabeverzeichnis kopieren (wenn neuer) -->
<None Update="settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+3
View File
@@ -0,0 +1,3 @@
<Solution>
<Project Path="IBKRTrader.csproj" />
</Solution>
@@ -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;
/// <summary>
/// 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.
/// </summary>
public static class CongressTradingModule
{
public const string ModuleKey = "CT";
/// <summary>Registriert alle Modul-Services im DI-Container.</summary>
public static void Register(IServiceCollection services)
{
// Datenbank
services.AddSingleton<CongressMigrations>();
services.AddSingleton<CongressRepository>();
// Scraper
services.AddSingleton<CapitolTradesScraper>();
// Worker
services.AddSingleton<CongressHistoryImportWorker>();
services.AddSingleton<CongressScrapeWorker>();
}
/// <summary>
/// Führt die Modul-Migrationen aus und resettet ggf. den Fortschritt.
/// </summary>
public static async Task InitializeAsync(IServiceProvider provider)
{
var migrations = provider.GetRequiredService<CongressMigrations>();
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<CongressRepository>();
// await repo.ResetHistoryImportAsync();
}
/// <summary>Gibt die Modul-Worker für die WorkerEngine zurück.</summary>
public static IEnumerable<IWorker> GetWorkers(IServiceProvider provider) =>
[
provider.GetRequiredService<CongressHistoryImportWorker>(),
provider.GetRequiredService<CongressScrapeWorker>()
];
}
@@ -0,0 +1,80 @@
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Modules.CongressTrading.Database;
/// <summary>
/// Legt die ct_xxx-Tabellen idempotent an.
/// Namensschema: ct_{tabellenname} gemäß Architekturregeln.
/// </summary>
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`);");
}
}
@@ -0,0 +1,174 @@
using Dapper;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
using IBKRTrader.Modules.CongressTrading.Models;
namespace IBKRTrader.Modules.CongressTrading.Database;
/// <summary>
/// Datenbankzugriff für das CongressTrading-Modul.
/// Kapselt alle INSERT/SELECT auf ct_congressMember und ct_trade.
/// </summary>
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<bool> MemberExistsAsync(string bioId)
{
var count = await _db.ExecuteScalarAsync<int>(
"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<HashSet<string>> GetAllMemberBioIdsAsync()
{
var ids = await _db.QueryAsync<string>(
"SELECT bio_id FROM `ct_congressMember`");
return ids.ToHashSet();
}
// ─── CongressTrade ────────────────────────────────────────────────────────
public async Task<bool> TradeExistsAsync(string tradeId)
{
var count = await _db.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM `ct_trade` WHERE trade_id = @tradeId",
new { tradeId });
return count > 0;
}
public async Task<HashSet<string>> GetAllTradeIdsAsync()
{
var ids = await _db.QueryAsync<string>(
"SELECT trade_id FROM `ct_trade`");
return ids.ToHashSet();
}
/// <summary>Fügt einen Trade ein. Ignoriert Duplikate (INSERT IGNORE).</summary>
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
});
}
/// <summary>Aktualisiert die Detail-Felder eines bereits vorhandenen Trades.</summary>
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")
});
}
/// <summary>Gibt Trade-IDs zurück für die noch keine Details geholt wurden.</summary>
public async Task<IEnumerable<string>> GetTradesWithoutDetailsAsync(int limit = 500)
=> await _db.QueryAsync<string>(
"SELECT trade_id FROM `ct_trade` WHERE details_fetched = 0 LIMIT @limit",
new { limit });
public async Task<int> GetTradeCountAsync()
=> await _db.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM `ct_trade`");
public async Task<int> GetMemberCountAsync()
=> await _db.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM `ct_congressMember`");
// ─── core_settings Flags ──────────────────────────────────────────────────
public async Task<string?> GetSettingAsync(string key)
{
return await _db.QueryFirstOrDefaultAsync<string>(
"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 ────────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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.");
}
}
@@ -0,0 +1,15 @@
namespace IBKRTrader.Modules.CongressTrading.Models;
/// <summary>Kongressmitglied aus capitoltrades.com</summary>
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;
}
@@ -0,0 +1,23 @@
namespace IBKRTrader.Modules.CongressTrading.Models;
/// <summary>Ein einzelner Kongress-Trade aus capitoltrades.com</summary>
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;
/// <summary>Transient wird direkt in ct_congressMember gespeichert.</summary>
public CongressMember? MemberSnapshot { get; set; }
}
@@ -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<CtTrade>? 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 ─────────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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<int> 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<CongressTrade> Trades, HashSet<string> 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<CongressTrade>();
var memberIds = new HashSet<string>();
// 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 ────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>Findet das Ende eines JSON-Strings (schließendes " das nicht escaped ist).</summary>
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;
}
/// <summary>Extrahiert das "data":[...] Array aus dem un-escaped Payload.</summary>
private static List<CtTrade>? 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<List<CtTrade>>(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);
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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";
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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}";
}
}
+85
View File
@@ -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<LoggingService>();
// Core: Database
services.AddSingleton<DatabaseService>();
services.AddSingleton<CoreMigrations>();
services.AddSingleton<IBKRMigrations>();
// Core: IBKR Services
services.AddSingleton<IBKRGatewayService>();
services.AddSingleton<IBKRMarketDataRepository>();
// Core: Other Services
services.AddSingleton<BudgetService>();
services.AddSingleton<TradeHistoryService>();
services.AddSingleton<AIModelService>();
// Core: Built-In Worker
services.AddSingleton<BackupWorker>();
services.AddSingleton<WebserverService>();
services.AddSingleton<WebApiService>();
services.AddSingleton<IBKRInstrumentSyncWorker>();
services.AddSingleton<IBKRPriceHistoryWorker>();
// ── Module registrieren ──────────────────────────────────────────────
CongressTradingModule.Register(services);
// ── IWorker-Sammlung: Core + Module ──────────────────────────────────
services.AddSingleton<IEnumerable<IWorker>>(sp => new IWorker[]
{
// Core-Worker
sp.GetRequiredService<BackupWorker>(),
sp.GetRequiredService<WebserverService>(),
sp.GetRequiredService<WebApiService>(),
// IBKR Market Data Worker
sp.GetRequiredService<IBKRInstrumentSyncWorker>(),
sp.GetRequiredService<IBKRPriceHistoryWorker>(),
}
// Modul-Worker dynamisch anhängen
.Concat(CongressTradingModule.GetWorkers(sp))
.ToArray());
services.AddSingleton<WorkerEngine>();
services.AddSingleton<Form1>();
var provider = services.BuildServiceProvider();
// WebApiService bekommt Engine-Referenz (zirkuläre Abhängigkeit auflösen)
var webApi = provider.GetRequiredService<WebApiService>();
var engine = provider.GetRequiredService<WorkerEngine>();
webApi.SetEngine(engine);
Application.Run(provider.GetRequiredService<Form1>());
}
}
+33
View File
@@ -0,0 +1,33 @@
using IBKRTrader.Core.Logging;
namespace IBKRTrader.UI;
/// <summary>
/// Steuert das Log-Panel (RichTextBox im Logs-Tab).
/// Bietet Clear- und Filter-Funktionalität.
/// </summary>
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);
}
+77
View File
@@ -0,0 +1,77 @@
using System.ComponentModel;
using IBKRTrader.Core.Workers;
namespace IBKRTrader.UI;
/// <summary>
/// Richtet dgv_workerlist vollständig ein:
/// Spalten, Binding, Formatierung, Kontext-Menü.
/// </summary>
public static class WorkerListBindingSource
{
public static void Setup(DataGridView dgv, BindingList<WorkerInfo> 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
};
}
+58
View File
@@ -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))
}
+1
View File
File diff suppressed because one or more lines are too long
+129
View File
@@ -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!
+29
View File
@@ -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
}
}
}