@
R1: Umbau auf Multi-Projekt-Struktur (Vorbild PolytraderSharp) Kurskorrektur: Vorbild ist PolytraderSharp (C#), nicht die veraltete Python-Version. Reiner Strukturumbau, Verhalten unveraendert. - src/IBKRTrader.Core (classlib): Core-Code + UI-Contract (ModuleFormBase, WindowManager) - src/IBKRTrader.Modules.CongressTrading (classlib, referenziert nur Core) - Root: IBKRTrader.App (WinExe) referenziert Core + Modul - tests/IBKRTrader.Tests: Referenzen auf Core + Modul - Neue .slnx; ungenutztes HtmlAgilityPack entfernt - docs/ARCHITECTURE.md: korrigierter Ziel-Stand + R-Phasenplan - Build + 38/38 Tests gruen; App startet Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @
This commit is contained in:
@@ -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,91 @@
|
||||
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();
|
||||
await CreateCorePositionAsync();
|
||||
_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;");
|
||||
|
||||
private Task CreateCorePositionAsync() => _db.ExecuteAsync(@"
|
||||
CREATE TABLE IF NOT EXISTS `core_position` (
|
||||
`module` VARCHAR(50) NOT NULL,
|
||||
`symbol` VARCHAR(20) NOT NULL,
|
||||
`quantity` INT NOT NULL DEFAULT 0,
|
||||
`avg_price` DECIMAL(18,4) NOT NULL DEFAULT 0,
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`module`, `symbol`)
|
||||
) 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,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Der Core stellt den UI-Contract (ModuleFormBase/WindowManager, später IModuleUiHost/ModuleView)
|
||||
bereit, damit Module designbare Forms beitragen können. -->
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MySqlConnector" Version="2.4.0" />
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Erlaubt dem Testprojekt, interne Helfer zu testen. -->
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>IBKRTrader.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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,44 @@
|
||||
using IBKRTrader.Core.Workers;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader.Core.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Vertrag für ein eigenständiges Trading-Modul.
|
||||
///
|
||||
/// Architektur-Regel: Der Core kennt konkrete Module NICHT. Module hängen sich
|
||||
/// ausschließlich über diesen Vertrag und den DI-Container ein
|
||||
/// (Abhängigkeit nur Modul → Core, niemals umgekehrt).
|
||||
///
|
||||
/// Lebenszyklus:
|
||||
/// 1. <see cref="RegisterServices"/> – vor dem Bauen des DI-Containers
|
||||
/// 2. <see cref="InitializeAsync"/> – beim App-Start (Migrationen etc.)
|
||||
/// 3. <see cref="GetWorkers"/> – Worker werden der WorkerEngine übergeben
|
||||
/// 4. <see cref="CreateWindow"/> – Launcher öffnet das Modul-Fenster
|
||||
/// </summary>
|
||||
public interface IModule
|
||||
{
|
||||
/// <summary>Eindeutiges Modul-Kürzel, zugleich DB-Tabellen-Präfix (z. B. "CT").</summary>
|
||||
string Key { get; }
|
||||
|
||||
/// <summary>Anzeigename im Launcher (z. B. "Congress Trading").</summary>
|
||||
string DisplayName { get; }
|
||||
|
||||
/// <summary>Kurzbeschreibung der Strategie für den Launcher.</summary>
|
||||
string Description { get; }
|
||||
|
||||
/// <summary>Modul-Version.</summary>
|
||||
string Version { get; }
|
||||
|
||||
/// <summary>Registriert alle Modul-Services im DI-Container (vor dem Build).</summary>
|
||||
void RegisterServices(IServiceCollection services);
|
||||
|
||||
/// <summary>Führt Modul-Migrationen und einmalige Initialisierung aus (beim Start).</summary>
|
||||
Task InitializeAsync(IServiceProvider provider);
|
||||
|
||||
/// <summary>Liefert die Worker/Services des Moduls für die WorkerEngine.</summary>
|
||||
IEnumerable<IWorker> GetWorkers(IServiceProvider provider);
|
||||
|
||||
/// <summary>Erzeugt das eigenständige Fenster des Moduls (vom Launcher geöffnet).</summary>
|
||||
Form CreateWindow(IServiceProvider provider);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace IBKRTrader.Core.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Zentrale Registry aller geladenen Module.
|
||||
/// Wird in Program.cs mit den Modul-Instanzen befüllt und als Singleton
|
||||
/// bereitgestellt, damit Launcher und Startup-Code über die Module iterieren
|
||||
/// können, ohne konkrete Modultypen zu kennen.
|
||||
/// </summary>
|
||||
public class ModuleRegistry
|
||||
{
|
||||
private readonly List<IModule> _modules;
|
||||
|
||||
public ModuleRegistry(IEnumerable<IModule> modules)
|
||||
=> _modules = modules?.ToList() ?? throw new ArgumentNullException(nameof(modules));
|
||||
|
||||
/// <summary>Alle registrierten Module in Registrierungsreihenfolge.</summary>
|
||||
public IReadOnlyList<IModule> Modules => _modules;
|
||||
|
||||
/// <summary>Findet ein Modul anhand seines Kürzels (case-insensitive) oder null.</summary>
|
||||
public IModule? Find(string key)
|
||||
=> _modules.FirstOrDefault(m => string.Equals(m.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using System.ComponentModel;
|
||||
using IBKRTrader.Core.Trading;
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
// ─── Trading ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[TypeConverter(typeof(ExpandableObjectConverter))]
|
||||
public class TradingSettings
|
||||
{
|
||||
[Category("Trading")]
|
||||
[DisplayName("Modus")]
|
||||
[Description("Handelsmodus: Paper (Test-Account, Port 4002) oder Live (Port 4001)")]
|
||||
public string Mode { get; set; } = "Paper";
|
||||
|
||||
[Category("Trading")]
|
||||
[DisplayName("Trading aktiv")]
|
||||
[Description("Globaler Hauptschalter. Nur wenn aktiv werden Orders ausgeführt.")]
|
||||
public bool TradingEnabled { get; set; } = false;
|
||||
|
||||
[Category("Trading")]
|
||||
[DisplayName("Max. je Trade (%)")]
|
||||
[Description("Maximaler Nominalwert einer einzelnen Position in % des Kontowerts")]
|
||||
public double MaxTradePercent { get; set; } = 5.0;
|
||||
|
||||
[Category("Trading")]
|
||||
[DisplayName("Max. je Modul (%)")]
|
||||
[Description("Maximaler Gesamt-Nominalwert aller Positionen eines Moduls in % des Kontowerts")]
|
||||
public double MaxPositionPercentPerModule { get; set; } = 20.0;
|
||||
|
||||
[Category("Trading")]
|
||||
[DisplayName("Max. Slippage (%)")]
|
||||
[Description("Maximal erlaubte Abweichung zwischen Limit-Preis und aktuellem Kurs")]
|
||||
public double MaxSlippagePercent { get; set; } = 5.0;
|
||||
|
||||
[Category("Trading")]
|
||||
[DisplayName("Gewinnziel (%)")]
|
||||
[Description("Ziel-Gewinn einer Position in Prozent (für spätere Exit-Logik)")]
|
||||
public double ProfitTargetPercent { get; set; } = 50.0;
|
||||
|
||||
/// <summary>Parst den Modus in das Enum (Fallback: Paper).</summary>
|
||||
public TradingMode ParsedMode =>
|
||||
Enum.TryParse<TradingMode>(Mode, true, out var m) ? m : TradingMode.Paper;
|
||||
|
||||
public override string ToString() => $"{Mode} – {(TradingEnabled ? "aktiv" : "inaktiv")}";
|
||||
}
|
||||
|
||||
// ─── 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();
|
||||
|
||||
[Category("Trading")]
|
||||
[DisplayName("Trading")]
|
||||
[Description("Handelsmodus und Risiko-Parameter")]
|
||||
public TradingSettings Trading { 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,96 @@
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Settings;
|
||||
|
||||
namespace IBKRTrader.Core.Trading;
|
||||
|
||||
/// <summary>
|
||||
/// Führt Modul-Signale aus: globaler Schalter → Kurs → Konto → Risiko → Order → Buchung.
|
||||
/// Kennt kein Modul – Module rufen nur <see cref="ExecuteAsync"/> mit ihrem Signal auf.
|
||||
/// </summary>
|
||||
public sealed class ExecutionService : IExecutionService
|
||||
{
|
||||
private readonly IBrokerClient _broker;
|
||||
private readonly IRiskService _risk;
|
||||
private readonly IPortfolioService _portfolio;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
public ExecutionService(
|
||||
IBrokerClient broker,
|
||||
IRiskService risk,
|
||||
IPortfolioService portfolio,
|
||||
SettingsService settings,
|
||||
LoggingService logger)
|
||||
{
|
||||
_broker = broker;
|
||||
_risk = risk;
|
||||
_portfolio = portfolio;
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ExecutionResult> ExecuteAsync(TradeSignal signal, CancellationToken ct = default)
|
||||
{
|
||||
var trading = _settings.Settings.Trading;
|
||||
var module = signal.SourceModule;
|
||||
|
||||
// 1. Globaler Hauptschalter
|
||||
if (!trading.TradingEnabled)
|
||||
return Log(module, ExecutionResult.Skip("Trading global deaktiviert."));
|
||||
|
||||
// 2. Kurs
|
||||
var quote = await _broker.GetQuoteAsync(signal.Symbol, ct);
|
||||
if (quote is null || quote.Last <= 0)
|
||||
return Log(module, ExecutionResult.Skip($"Kein Kurs für {signal.Symbol} verfügbar."));
|
||||
|
||||
// 3. Konto + 4. bestehende Exposure/Position
|
||||
var account = await _broker.GetAccountStateAsync(ct);
|
||||
var exposure = await _portfolio.GetModuleExposureAsync(module, ct);
|
||||
var existingQty = await _portfolio.GetPositionQuantityAsync(module, signal.Symbol, ct);
|
||||
|
||||
// 5. Risikoprüfung
|
||||
var context = new RiskContext
|
||||
{
|
||||
Price = quote.Last,
|
||||
NetLiquidation = account.NetLiquidation,
|
||||
ModuleExposure = exposure,
|
||||
ExistingQuantity = existingQty
|
||||
};
|
||||
var riskParams = new RiskParameters(
|
||||
(decimal)trading.MaxTradePercent,
|
||||
(decimal)trading.MaxPositionPercentPerModule,
|
||||
(decimal)trading.MaxSlippagePercent);
|
||||
var decision = _risk.Evaluate(signal, context, riskParams);
|
||||
if (!decision.Approved)
|
||||
return Log(module, ExecutionResult.Skip(decision.Reason));
|
||||
|
||||
// 6. Order platzieren
|
||||
var order = new OrderRequest
|
||||
{
|
||||
Symbol = signal.Symbol,
|
||||
Side = signal.Side,
|
||||
Quantity = decision.Quantity,
|
||||
Type = signal.LimitPrice.HasValue ? OrderType.Limit : OrderType.Market,
|
||||
LimitPrice = signal.LimitPrice
|
||||
};
|
||||
|
||||
var result = await _broker.PlaceOrderAsync(order, ct);
|
||||
if (!result.Success)
|
||||
return Log(module, ExecutionResult.Error(result.Error ?? "Order fehlgeschlagen.", result));
|
||||
|
||||
// 7. Buchung
|
||||
await _portfolio.RecordFillAsync(
|
||||
module, signal.Symbol, signal.Side,
|
||||
result.FilledQuantity, result.AvgFillPrice, result.OrderId, ct);
|
||||
|
||||
return Log(module, ExecutionResult.Execute(result));
|
||||
}
|
||||
|
||||
private ExecutionResult Log(string module, ExecutionResult result)
|
||||
{
|
||||
var text = $"[{result.Action}] {result.Reason}";
|
||||
if (result.Action == "ERROR") _logger.Error(module, text);
|
||||
else _logger.Info(module, text);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace IBKRTrader.Core.Trading;
|
||||
|
||||
/// <summary>
|
||||
/// Broker-Abstraktion (Kurse, Konto, Order-Ausführung).
|
||||
/// Externer Seam: in Tests gemockt, produktiv von einem IBKR-Adapter implementiert.
|
||||
/// </summary>
|
||||
public interface IBrokerClient
|
||||
{
|
||||
/// <summary>Aktuelle Kurs-Momentaufnahme oder null, wenn nicht verfügbar.</summary>
|
||||
Task<Quote?> GetQuoteAsync(string symbol, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Kontostand-Momentaufnahme.</summary>
|
||||
Task<AccountState> GetAccountStateAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Platziert eine Order und gibt das Ergebnis zurück.</summary>
|
||||
Task<OrderResult> PlaceOrderAsync(OrderRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace IBKRTrader.Core.Trading;
|
||||
|
||||
/// <summary>
|
||||
/// Zentrale Ausführungs-Pipeline. Module übergeben ihre <see cref="TradeSignal"/>e hier;
|
||||
/// der Core prüft Risiko, führt aus und verbucht.
|
||||
/// </summary>
|
||||
public interface IExecutionService
|
||||
{
|
||||
Task<ExecutionResult> ExecuteAsync(TradeSignal signal, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace IBKRTrader.Core.Trading;
|
||||
|
||||
/// <summary>
|
||||
/// Buchführung über offene Positionen und Ausführungen (core_position, core_trade_history).
|
||||
/// Externer Seam gegenüber der Datenbank: in Tests gemockt.
|
||||
/// </summary>
|
||||
public interface IPortfolioService
|
||||
{
|
||||
/// <summary>Summe des offenen Nominalwerts eines Moduls.</summary>
|
||||
Task<decimal> GetModuleExposureAsync(string module, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Gehaltene Stückzahl eines Moduls für ein Symbol (0, wenn keine Position).</summary>
|
||||
Task<int> GetPositionQuantityAsync(string module, string symbol, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Verbucht einen Fill: aktualisiert Position, Budget und Trade-Historie.</summary>
|
||||
Task RecordFillAsync(
|
||||
string module, string symbol, TradeSide side,
|
||||
int quantity, decimal price, string? orderId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Alle offenen Positionen eines Moduls.</summary>
|
||||
Task<IReadOnlyList<Position>> GetPositionsAsync(string module, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace IBKRTrader.Core.Trading;
|
||||
|
||||
/// <summary>Bewertet ein Signal gegen die Risiko-Parameter und liefert die Stückzahl.</summary>
|
||||
public interface IRiskService
|
||||
{
|
||||
RiskDecision Evaluate(TradeSignal signal, RiskContext context, RiskParameters risk);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using IBKRTrader.Core.Logging;
|
||||
|
||||
namespace IBKRTrader.Core.Trading;
|
||||
|
||||
/// <summary>
|
||||
/// Sicherer Standard-Broker: handelt NIEMALS.
|
||||
/// Wird registriert, bis der echte IBKR-Adapter angebunden und gegen den
|
||||
/// Paper-Gateway verifiziert ist. So kann keine Order versehentlich rausgehen.
|
||||
/// </summary>
|
||||
public sealed class NullBrokerClient : IBrokerClient
|
||||
{
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
public NullBrokerClient(LoggingService logger) => _logger = logger;
|
||||
|
||||
public Task<Quote?> GetQuoteAsync(string symbol, CancellationToken ct = default)
|
||||
=> Task.FromResult<Quote?>(null);
|
||||
|
||||
public Task<AccountState> GetAccountStateAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(new AccountState(0m, 0m));
|
||||
|
||||
public Task<OrderResult> PlaceOrderAsync(OrderRequest request, CancellationToken ct = default)
|
||||
{
|
||||
_logger.Warn("Core",
|
||||
$"NullBrokerClient: Order NICHT ausgeführt ({request.Side} {request.Quantity}x {request.Symbol}) " +
|
||||
"– echter IBKR-Broker noch nicht angebunden.");
|
||||
return Task.FromResult(OrderResult.Fail("Broker nicht angebunden (NullBrokerClient)."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using IBKRTrader.Core.Budget;
|
||||
using IBKRTrader.Core.Database;
|
||||
using IBKRTrader.Core.Logging;
|
||||
|
||||
namespace IBKRTrader.Core.Trading;
|
||||
|
||||
/// <summary>
|
||||
/// DB-gestützte Buchführung über offene Positionen (core_position),
|
||||
/// Trade-Historie (core_trade_history) und Budget (core_budget).
|
||||
/// </summary>
|
||||
public sealed class PortfolioService : IPortfolioService
|
||||
{
|
||||
private readonly DatabaseService _db;
|
||||
private readonly TradeHistoryService _history;
|
||||
private readonly BudgetService _budget;
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
public PortfolioService(
|
||||
DatabaseService db,
|
||||
TradeHistoryService history,
|
||||
BudgetService budget,
|
||||
LoggingService logger)
|
||||
{
|
||||
_db = db;
|
||||
_history = history;
|
||||
_budget = budget;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private sealed class PosDto
|
||||
{
|
||||
public int Quantity { get; set; }
|
||||
public decimal AvgPrice { get; set; }
|
||||
}
|
||||
|
||||
public async Task<decimal> GetModuleExposureAsync(string module, CancellationToken ct = default)
|
||||
{
|
||||
var sum = await _db.ExecuteScalarAsync<decimal?>(
|
||||
"SELECT SUM(quantity * avg_price) FROM `core_position` WHERE module = @module",
|
||||
new { module });
|
||||
return sum ?? 0m;
|
||||
}
|
||||
|
||||
public async Task<int> GetPositionQuantityAsync(string module, string symbol, CancellationToken ct = default)
|
||||
{
|
||||
var row = await _db.QueryFirstOrDefaultAsync<PosDto>(
|
||||
"SELECT quantity AS Quantity, avg_price AS AvgPrice FROM `core_position` " +
|
||||
"WHERE module = @module AND symbol = @symbol",
|
||||
new { module, symbol });
|
||||
return row?.Quantity ?? 0;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Position>> GetPositionsAsync(string module, CancellationToken ct = default)
|
||||
{
|
||||
var rows = await _db.QueryAsync<Position>(
|
||||
"SELECT module AS Module, symbol AS Symbol, quantity AS Quantity, avg_price AS AvgPrice " +
|
||||
"FROM `core_position` WHERE module = @module AND quantity > 0",
|
||||
new { module });
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task RecordFillAsync(
|
||||
string module, string symbol, TradeSide side,
|
||||
int quantity, decimal price, string? orderId, CancellationToken ct = default)
|
||||
{
|
||||
if (quantity <= 0) return;
|
||||
|
||||
var action = side == TradeSide.Buy ? "BUY" : "SELL";
|
||||
await _history.RecordTradeAsync(module, symbol, action, quantity, price, orderId);
|
||||
|
||||
var current = await _db.QueryFirstOrDefaultAsync<PosDto>(
|
||||
"SELECT quantity AS Quantity, avg_price AS AvgPrice FROM `core_position` " +
|
||||
"WHERE module = @module AND symbol = @symbol",
|
||||
new { module, symbol });
|
||||
|
||||
var oldQty = current?.Quantity ?? 0;
|
||||
var oldAvg = current?.AvgPrice ?? 0m;
|
||||
|
||||
if (side == TradeSide.Buy)
|
||||
{
|
||||
var newQty = oldQty + quantity;
|
||||
var newAvg = oldQty > 0 ? (oldQty * oldAvg + quantity * price) / newQty : price;
|
||||
await UpsertPositionAsync(module, symbol, newQty, newAvg);
|
||||
await _budget.ReserveBudgetAsync(module, quantity * price);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newQty = oldQty - quantity;
|
||||
if (newQty <= 0)
|
||||
await DeletePositionAsync(module, symbol);
|
||||
else
|
||||
await UpsertPositionAsync(module, symbol, newQty, oldAvg);
|
||||
await _budget.ReleaseBudgetAsync(module, quantity * price);
|
||||
}
|
||||
|
||||
_logger.Info(module, $"Position gebucht: {action} {quantity}x {symbol} @ {price:F2}");
|
||||
}
|
||||
|
||||
private Task UpsertPositionAsync(string module, string symbol, int quantity, decimal avgPrice) =>
|
||||
_db.ExecuteAsync(@"
|
||||
INSERT INTO `core_position` (module, symbol, quantity, avg_price)
|
||||
VALUES (@module, @symbol, @quantity, @avgPrice)
|
||||
ON DUPLICATE KEY UPDATE quantity = @quantity, avg_price = @avgPrice",
|
||||
new { module, symbol, quantity, avgPrice });
|
||||
|
||||
private Task DeletePositionAsync(string module, string symbol) =>
|
||||
_db.ExecuteAsync(
|
||||
"DELETE FROM `core_position` WHERE module = @module AND symbol = @symbol",
|
||||
new { module, symbol });
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace IBKRTrader.Core.Trading;
|
||||
|
||||
/// <summary>
|
||||
/// Reine Risiko-/Sizing-Logik – keine externen Abhängigkeiten, vollständig unit-testbar.
|
||||
///
|
||||
/// Regeln:
|
||||
/// - Kauf: Nominalwert = min(Wunsch, Kontowert × MaxTradePercent); Stückzahl = floor(Nominal / Kurs).
|
||||
/// Ablehnung bei ungültigem Kurs, Stückzahl < 1, Überschreitung des Modul-Limits
|
||||
/// oder zu hoher Slippage (bei Limit-Order).
|
||||
/// - Verkauf: schließt die vorhandene Position (Stückzahl = gehaltene Menge);
|
||||
/// Ablehnung, wenn keine Position vorhanden ist.
|
||||
/// </summary>
|
||||
public sealed class RiskService : IRiskService
|
||||
{
|
||||
public RiskDecision Evaluate(TradeSignal signal, RiskContext context, RiskParameters risk)
|
||||
{
|
||||
if (context.Price <= 0)
|
||||
return RiskDecision.Reject("Ungültiger Kurs (<= 0).");
|
||||
|
||||
if (SlippageTooHigh(signal, context, risk, out var slipReason))
|
||||
return RiskDecision.Reject(slipReason);
|
||||
|
||||
return signal.Side == TradeSide.Sell
|
||||
? EvaluateSell(context)
|
||||
: EvaluateBuy(context, signal, risk);
|
||||
}
|
||||
|
||||
private static RiskDecision EvaluateSell(RiskContext context)
|
||||
{
|
||||
if (context.ExistingQuantity <= 0)
|
||||
return RiskDecision.Reject("Keine Position zum Verkauf vorhanden.");
|
||||
|
||||
return RiskDecision.Approve(context.ExistingQuantity, "Verkauf schließt Position.");
|
||||
}
|
||||
|
||||
private static RiskDecision EvaluateBuy(RiskContext context, TradeSignal signal, RiskParameters risk)
|
||||
{
|
||||
if (context.NetLiquidation <= 0)
|
||||
return RiskDecision.Reject("Kontowert unbekannt oder 0.");
|
||||
|
||||
var maxNotional = context.NetLiquidation * (risk.MaxTradePercent / 100m);
|
||||
var notional = signal.SuggestedNotional is { } wish && wish > 0
|
||||
? Math.Min(wish, maxNotional)
|
||||
: maxNotional;
|
||||
|
||||
var quantity = (int)Math.Floor(notional / context.Price);
|
||||
if (quantity < 1)
|
||||
return RiskDecision.Reject("Positionsgröße < 1 Stück bei aktuellem Kurs/Budget.");
|
||||
|
||||
var projectedExposure = context.ModuleExposure + quantity * context.Price;
|
||||
var moduleLimit = context.NetLiquidation * (risk.MaxPositionPercentPerModule / 100m);
|
||||
if (projectedExposure > moduleLimit)
|
||||
return RiskDecision.Reject(
|
||||
$"Modul-Limit überschritten ({projectedExposure:F0} > {moduleLimit:F0}).");
|
||||
|
||||
return RiskDecision.Approve(quantity, $"{quantity} Stück freigegeben.");
|
||||
}
|
||||
|
||||
private static bool SlippageTooHigh(
|
||||
TradeSignal signal, RiskContext context, RiskParameters risk, out string reason)
|
||||
{
|
||||
reason = "";
|
||||
if (signal.LimitPrice is not { } limit || limit <= 0)
|
||||
return false;
|
||||
|
||||
var deviationPct = Math.Abs(context.Price - limit) / limit * 100m;
|
||||
if (deviationPct > risk.MaxSlippagePercent)
|
||||
{
|
||||
reason = $"Slippage zu hoch ({deviationPct:F1}% > {risk.MaxSlippagePercent:F1}%).";
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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,124 @@
|
||||
namespace IBKRTrader.Core.Trading;
|
||||
|
||||
/// <summary>Kauf oder Verkauf.</summary>
|
||||
public enum TradeSide { Buy, Sell }
|
||||
|
||||
/// <summary>Order-Typ.</summary>
|
||||
public enum OrderType { Market, Limit }
|
||||
|
||||
/// <summary>Handelsmodus – Paper-Account (Test) oder Live.</summary>
|
||||
public enum TradingMode { Paper, Live }
|
||||
|
||||
/// <summary>
|
||||
/// Signal, das ein Modul an den <see cref="IExecutionService"/> übergibt.
|
||||
/// Das Modul liefert nur die Absicht – Sizing, Risiko und Ausführung macht der Core.
|
||||
/// </summary>
|
||||
public sealed record TradeSignal
|
||||
{
|
||||
/// <summary>Ticker-Symbol (z. B. "AAPL").</summary>
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>Kauf oder Verkauf.</summary>
|
||||
public required TradeSide Side { get; init; }
|
||||
|
||||
/// <summary>Kürzel des auslösenden Moduls (z. B. "CT").</summary>
|
||||
public required string SourceModule { get; init; }
|
||||
|
||||
/// <summary>Begründung des Signals (für Logging/Buchführung).</summary>
|
||||
public string Reason { get; init; } = "";
|
||||
|
||||
/// <summary>Optionaler Limit-Preis. null = Market-Order.</summary>
|
||||
public decimal? LimitPrice { get; init; }
|
||||
|
||||
/// <summary>Optionaler Nominalwert-Wunsch; sonst greift das Risiko-Sizing.</summary>
|
||||
public decimal? SuggestedNotional { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Konkrete Order-Anforderung an den Broker.</summary>
|
||||
public sealed record OrderRequest
|
||||
{
|
||||
public required string Symbol { get; init; }
|
||||
public required TradeSide Side { get; init; }
|
||||
public required int Quantity { get; init; }
|
||||
public required OrderType Type { get; init; }
|
||||
public decimal? LimitPrice { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Ergebnis einer Order-Platzierung.</summary>
|
||||
public sealed record OrderResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? OrderId { get; init; }
|
||||
public int FilledQuantity { get; init; }
|
||||
public decimal AvgFillPrice { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static OrderResult Filled(string orderId, int qty, decimal price) =>
|
||||
new() { Success = true, OrderId = orderId, FilledQuantity = qty, AvgFillPrice = price };
|
||||
|
||||
public static OrderResult Fail(string error) =>
|
||||
new() { Success = false, Error = error };
|
||||
}
|
||||
|
||||
/// <summary>Momentaufnahme eines Kurses.</summary>
|
||||
public sealed record Quote(string Symbol, decimal Last, decimal Bid, decimal Ask);
|
||||
|
||||
/// <summary>Kontostand-Momentaufnahme des Brokers.</summary>
|
||||
public sealed record AccountState(decimal NetLiquidation, decimal AvailableFunds);
|
||||
|
||||
/// <summary>Offene Position eines Moduls.</summary>
|
||||
public sealed record Position(string Module, string Symbol, int Quantity, decimal AvgPrice)
|
||||
{
|
||||
public decimal Notional => Quantity * AvgPrice;
|
||||
}
|
||||
|
||||
/// <summary>Kontext für die Risikobewertung eines Signals.</summary>
|
||||
public sealed record RiskContext
|
||||
{
|
||||
/// <summary>Aktueller Kurs des Symbols.</summary>
|
||||
public required decimal Price { get; init; }
|
||||
|
||||
/// <summary>Netto-Liquidationswert des Kontos.</summary>
|
||||
public required decimal NetLiquidation { get; init; }
|
||||
|
||||
/// <summary>Aktuell vom Modul gehaltener Nominalwert (Summe offener Positionen).</summary>
|
||||
public decimal ModuleExposure { get; init; }
|
||||
|
||||
/// <summary>Bereits gehaltene Stückzahl für das Signal-Symbol.</summary>
|
||||
public int ExistingQuantity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Aus den Settings abgeleitete Risiko-Parameter.</summary>
|
||||
public sealed record RiskParameters(
|
||||
decimal MaxTradePercent,
|
||||
decimal MaxPositionPercentPerModule,
|
||||
decimal MaxSlippagePercent);
|
||||
|
||||
/// <summary>Entscheidung der Risikoprüfung.</summary>
|
||||
public sealed record RiskDecision
|
||||
{
|
||||
public bool Approved { get; init; }
|
||||
public int Quantity { get; init; }
|
||||
public string Reason { get; init; } = "";
|
||||
|
||||
public static RiskDecision Reject(string reason) =>
|
||||
new() { Approved = false, Quantity = 0, Reason = reason };
|
||||
|
||||
public static RiskDecision Approve(int quantity, string reason = "OK") =>
|
||||
new() { Approved = true, Quantity = quantity, Reason = reason };
|
||||
}
|
||||
|
||||
/// <summary>Ergebnis einer Signal-Ausführung durch den <see cref="IExecutionService"/>.</summary>
|
||||
public sealed record ExecutionResult
|
||||
{
|
||||
/// <summary>"EXECUTE", "SKIP" oder "ERROR".</summary>
|
||||
public required string Action { get; init; }
|
||||
public string Reason { get; init; } = "";
|
||||
public OrderResult? Order { get; init; }
|
||||
|
||||
public bool Executed => Action == "EXECUTE";
|
||||
|
||||
public static ExecutionResult Skip(string reason) => new() { Action = "SKIP", Reason = reason };
|
||||
public static ExecutionResult Error(string reason, OrderResult? order = null) => new() { Action = "ERROR", Reason = reason, Order = order };
|
||||
public static ExecutionResult Execute(OrderResult order) => new() { Action = "EXECUTE", Reason = "OK", Order = order };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace IBKRTrader.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Basisklasse für eigenständige Modul-Fenster.
|
||||
/// Stellt gemeinsame Grundeinstellungen (Größe, Startposition) bereit und
|
||||
/// hält den Modul-Key, damit der Launcher/WindowManager das Fenster zuordnen kann.
|
||||
/// </summary>
|
||||
public class ModuleFormBase : Form
|
||||
{
|
||||
/// <summary>Kürzel des zugehörigen Moduls (z. B. "CT").</summary>
|
||||
public string ModuleKey { get; }
|
||||
|
||||
// Parameterloser Ctor nur für den WinForms-Designer.
|
||||
protected ModuleFormBase() : this("") { }
|
||||
|
||||
protected ModuleFormBase(string moduleKey)
|
||||
{
|
||||
ModuleKey = moduleKey;
|
||||
Width = 900;
|
||||
Height = 600;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
ShowInTaskbar = true;
|
||||
MinimumSize = new Size(600, 400);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace IBKRTrader.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet die eigenständigen Modul-Fenster des Launchers.
|
||||
/// Pro Modul-Key wird höchstens ein Fenster gehalten: erneutes Öffnen
|
||||
/// fokussiert das bestehende Fenster statt ein zweites zu erzeugen.
|
||||
///
|
||||
/// Die tatsächlichen UI-Aktionen (Show/Focus) sind in überschreibbare
|
||||
/// Methoden gekapselt, damit die Tracking-Logik ohne Message-Loop testbar ist.
|
||||
/// </summary>
|
||||
public class WindowManager
|
||||
{
|
||||
private readonly Dictionary<string, Form> _open = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Öffnet das Fenster zum Key oder fokussiert ein bereits offenes.</summary>
|
||||
public Form OpenOrFocus(string key, Func<Form> factory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
|
||||
if (_open.TryGetValue(key, out var existing))
|
||||
{
|
||||
if (!existing.IsDisposed)
|
||||
{
|
||||
Focus(existing);
|
||||
return existing;
|
||||
}
|
||||
_open.Remove(key);
|
||||
}
|
||||
|
||||
var form = factory();
|
||||
_open[key] = form;
|
||||
form.FormClosed += OnFormClosed;
|
||||
Present(form);
|
||||
return form;
|
||||
}
|
||||
|
||||
/// <summary>True, wenn zum Key aktuell ein nicht-disposetes Fenster offen ist.</summary>
|
||||
public bool IsOpen(string key)
|
||||
=> _open.TryGetValue(key, out var f) && !f.IsDisposed;
|
||||
|
||||
/// <summary>Anzahl aktuell offener Fenster.</summary>
|
||||
public int OpenCount => _open.Values.Count(f => !f.IsDisposed);
|
||||
|
||||
/// <summary>Schließt alle offenen Fenster (z. B. beim Beenden des Launchers).</summary>
|
||||
public void CloseAll()
|
||||
{
|
||||
foreach (var f in _open.Values.ToList())
|
||||
{
|
||||
if (!f.IsDisposed)
|
||||
{
|
||||
f.FormClosed -= OnFormClosed;
|
||||
f.Close();
|
||||
}
|
||||
}
|
||||
_open.Clear();
|
||||
}
|
||||
|
||||
// ─── Überschreibbare UI-Aktionen (für Tests) ──────────────────────────────
|
||||
|
||||
/// <summary>Zeigt ein neu erzeugtes Fenster an.</summary>
|
||||
protected virtual void Present(Form form) => form.Show();
|
||||
|
||||
/// <summary>Bringt ein bestehendes Fenster in den Vordergrund.</summary>
|
||||
protected virtual void Focus(Form form)
|
||||
{
|
||||
if (form.WindowState == FormWindowState.Minimized)
|
||||
form.WindowState = FormWindowState.Normal;
|
||||
form.Activate();
|
||||
form.BringToFront();
|
||||
}
|
||||
|
||||
private void OnFormClosed(object? sender, FormClosedEventArgs e)
|
||||
{
|
||||
if (sender is not Form f) return;
|
||||
f.FormClosed -= OnFormClosed;
|
||||
|
||||
var entry = _open.FirstOrDefault(p => ReferenceEquals(p.Value, f));
|
||||
if (entry.Key is not null)
|
||||
_open.Remove(entry.Key);
|
||||
}
|
||||
}
|
||||
@@ -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,172 @@
|
||||
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 BeginRunLogAsync();
|
||||
await ExecuteAsync(ct);
|
||||
await EndRunLogAsync(logId, true);
|
||||
|
||||
Info.LastRuntime = DateTime.Now;
|
||||
Info.Status = WorkerStatus.Idle;
|
||||
Info.Info = $"OK – {Info.LastRuntime:HH:mm:ss}";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (logId > 0)
|
||||
await EndRunLogAsync(logId, false, "Abgebrochen");
|
||||
Info.Status = WorkerStatus.Stopped;
|
||||
Info.Info = "Abgebrochen";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (logId > 0)
|
||||
await EndRunLogAsync(logId, false, ex.Message);
|
||||
Logger.Error(Module, $"Fehler in Worker {Name}: {ex.Message}", ex);
|
||||
Info.Status = WorkerStatus.Error;
|
||||
Info.Info = $"Fehler: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DB-Log-Seam (überschreibbar für Unit-Tests) ──────────────────────────
|
||||
|
||||
/// <summary>Legt den Worker-Log-Eintrag an. Kapselt den DB-Zugriff (testbar).</summary>
|
||||
protected virtual Task<long> BeginRunLogAsync()
|
||||
=> Db.BeginWorkerLogAsync(Name, Module);
|
||||
|
||||
/// <summary>Schließt den Worker-Log-Eintrag ab. Kapselt den DB-Zugriff (testbar).</summary>
|
||||
protected virtual Task EndRunLogAsync(long logId, bool success, string? message = null)
|
||||
=> Db.EndWorkerLogAsync(logId, success, message);
|
||||
|
||||
// ─── Hilfsmethoden ────────────────────────────────────────────────────────
|
||||
|
||||
private static string FormatInterval(TimeSpan ts)
|
||||
{
|
||||
if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d";
|
||||
if (ts.TotalHours >= 1) return $"{(int)ts.TotalHours}h";
|
||||
return $"{(int)ts.TotalMinutes}m";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.ComponentModel;
|
||||
using IBKRTrader.Core.Logging;
|
||||
|
||||
namespace IBKRTrader.Core.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestriert alle registrierten IWorker.
|
||||
/// – Startet/stoppt Worker
|
||||
/// – Stellt BindingList für DataGridView bereit
|
||||
/// – Thread-sicher via ConcurrentDictionary
|
||||
/// </summary>
|
||||
public class WorkerEngine
|
||||
{
|
||||
private readonly IEnumerable<IWorker> _workers;
|
||||
private readonly LoggingService _logger;
|
||||
private readonly ConcurrentDictionary<string, IWorker> _registry = new();
|
||||
private CancellationTokenSource _cts = new();
|
||||
|
||||
/// <summary>Live-bindbare Liste für dgv_workerlist.</summary>
|
||||
public BindingList<WorkerInfo> WorkerInfos { get; } = [];
|
||||
|
||||
public WorkerEngine(IEnumerable<IWorker> workers, LoggingService logger)
|
||||
{
|
||||
_workers = workers;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ─── Engine-Lifecycle ─────────────────────────────────────────────────────
|
||||
|
||||
public async Task StartAllAsync()
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
foreach (var worker in _workers)
|
||||
{
|
||||
RegisterWorker(worker);
|
||||
|
||||
if (worker.Info.Active)
|
||||
await worker.StartAsync(_cts.Token);
|
||||
}
|
||||
|
||||
_logger.Info("Core", $"WorkerEngine gestartet – {_registry.Count} Worker/Services registriert.");
|
||||
}
|
||||
|
||||
public async Task StopAllAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
|
||||
var tasks = _registry.Values.Select(w => w.StopAsync());
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
_logger.Info("Core", "WorkerEngine gestoppt.");
|
||||
}
|
||||
|
||||
// ─── Einzelsteuerung ──────────────────────────────────────────────────────
|
||||
|
||||
public async Task StartWorkerAsync(string name)
|
||||
{
|
||||
if (!_registry.TryGetValue(name, out var worker)) return;
|
||||
worker.Info.Active = true;
|
||||
await worker.StartAsync(_cts.Token);
|
||||
_logger.Info("Core", $"Worker manuell gestartet: {name}");
|
||||
}
|
||||
|
||||
public async Task StopWorkerAsync(string name)
|
||||
{
|
||||
if (!_registry.TryGetValue(name, out var worker)) return;
|
||||
worker.Info.Active = false;
|
||||
await worker.StopAsync();
|
||||
_logger.Info("Core", $"Worker manuell gestoppt: {name}");
|
||||
}
|
||||
|
||||
public async Task TriggerWorkerAsync(string name)
|
||||
{
|
||||
if (!_registry.TryGetValue(name, out var worker)) return;
|
||||
await worker.TriggerAsync();
|
||||
_logger.Info("Core", $"Worker manuell ausgelöst: {name}");
|
||||
}
|
||||
|
||||
// ─── Intern ───────────────────────────────────────────────────────────────
|
||||
|
||||
private void RegisterWorker(IWorker worker)
|
||||
{
|
||||
_registry[worker.Name] = worker;
|
||||
|
||||
// WorkerInfo in BindingList eintragen (UI-Thread, falls nötig)
|
||||
if (WorkerInfos is { } list)
|
||||
{
|
||||
if (SynchronizationContext.Current != null)
|
||||
list.Add(worker.Info);
|
||||
else
|
||||
list.Add(worker.Info);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace IBKRTrader.Core.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel-Objekt für eine Zeile in dgv_workerlist.
|
||||
/// Implementiert INotifyPropertyChanged für automatisches DataGridView-Binding.
|
||||
/// </summary>
|
||||
public class WorkerInfo : INotifyPropertyChanged
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private bool _active;
|
||||
private string _type = "";
|
||||
private string _module = "";
|
||||
private string _workerName = "";
|
||||
private DateTime? _lastRuntime;
|
||||
private DateTime? _nextRuntime;
|
||||
private string _runEvery = "";
|
||||
private string _info = "";
|
||||
private WorkerStatus _status = WorkerStatus.Idle;
|
||||
|
||||
// ─── Properties ───────────────────────────────────────────────────────────
|
||||
|
||||
public bool Active { get => _active; set => Set(ref _active, value); }
|
||||
public string Type { get => _type; set => Set(ref _type, value); }
|
||||
public string Module { get => _module; set => Set(ref _module, value); }
|
||||
public string WorkerName { get => _workerName; set => Set(ref _workerName, value); }
|
||||
public DateTime? LastRuntime { get => _lastRuntime; set => Set(ref _lastRuntime, value); }
|
||||
public DateTime? NextRuntime { get => _nextRuntime; set => Set(ref _nextRuntime, value); }
|
||||
public string RunEvery { get => _runEvery; set => Set(ref _runEvery, value); }
|
||||
public string Info { get => _info; set => Set(ref _info, value); }
|
||||
|
||||
/// <summary>Interner Status – wird nicht direkt als DGV-Spalte verwendet,
|
||||
/// aber steuert die Info-Spalte.</summary>
|
||||
public WorkerStatus Status
|
||||
{
|
||||
get => _status;
|
||||
set
|
||||
{
|
||||
if (!Set(ref _status, value)) return;
|
||||
// Info-Text automatisch synchronisieren
|
||||
if (value == WorkerStatus.Idle && string.IsNullOrWhiteSpace(_info))
|
||||
Info = "Bereit";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hilfsmethode ─────────────────────────────────────────────────────────
|
||||
|
||||
private bool Set<T>(ref T field, T value, [CallerMemberName] string? prop = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace IBKRTrader.Core.Workers;
|
||||
|
||||
public enum WorkerStatus
|
||||
{
|
||||
Idle,
|
||||
Running,
|
||||
Error,
|
||||
Stopped,
|
||||
Disabled
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace IBKRTrader.Core.Workers;
|
||||
|
||||
public enum WorkerType
|
||||
{
|
||||
/// <summary>Läuft periodisch nach einem festen Zeitplan.</summary>
|
||||
Worker,
|
||||
|
||||
/// <summary>Läuft permanent (z. B. Webserver, WebAPI).</summary>
|
||||
Service
|
||||
}
|
||||
Reference in New Issue
Block a user