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