using System.Net.Http.Json; using System.Text.Json; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Settings; namespace IBKRTrader.Core.IBKR; /// /// HTTP-Client für die IBKR Client Portal Web API (Gateway). /// Alle API-Aufrufe laufen über den lokalen Client Portal Gateway /// (Standard: https://localhost:5000/v1/api). /// /// Voraussetzungen: /// - Client Portal Gateway muss laufen (Java-Prozess) /// - Session muss per Browser + 2FA authentifiziert sein /// public class IBKRGatewayService { private readonly SettingsService _settings; private readonly LoggingService _logger; private readonly HttpClient _http; private readonly SemaphoreSlim _rateLimiter; private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; public bool IsConnected { get; private set; } public IBKRGatewayService(SettingsService settings, LoggingService logger) { _settings = settings; _logger = logger; // HttpClient mit SSL-Bypass für localhost (Gateway hat kein signiertes Zertifikat) var handler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }; var apiSettings = _settings.Settings.IBKRWebApi; _http = new HttpClient(handler) { BaseAddress = new Uri(apiSettings.GatewayUrl.TrimEnd('/') + "/v1/api/"), Timeout = TimeSpan.FromSeconds(30) }; // Standard-Header setzen _http.DefaultRequestHeaders.Add("User-Agent", "IBKRTrader/1.0"); _http.DefaultRequestHeaders.Add("Accept", "*/*"); // Rate-Limiter: maximal N gleichzeitige Anfragen (wird seriell mit Delay verwendet) _rateLimiter = new SemaphoreSlim(1, 1); } // ─── Rate Limiting ─────────────────────────────────────────────────────── private async Task RateLimitAsync(CancellationToken ct) { await _rateLimiter.WaitAsync(ct); try { var delayMs = 1000 / Math.Max(1, _settings.Settings.IBKRWebApi.MaxRequestsPerSecond); await Task.Delay(delayMs, ct); } finally { _rateLimiter.Release(); } } // ─── Session / Auth ────────────────────────────────────────────────────── /// /// Prüft den Authentifizierungsstatus der Gateway-Session. /// public async Task CheckAuthStatusAsync(CancellationToken ct = default) { try { await RateLimitAsync(ct); var response = await _http.PostAsync("iserver/auth/status", null, ct); if (!response.IsSuccessStatusCode) { _logger.Warn("IBKR", $"Auth-Status-Check fehlgeschlagen: HTTP {(int)response.StatusCode}"); return null; } var result = await response.Content.ReadFromJsonAsync(JsonOpts, ct); IsConnected = result?.Authenticated == true; return result; } catch (HttpRequestException ex) { _logger.Warn("IBKR", $"Gateway nicht erreichbar: {ex.Message}"); IsConnected = false; return null; } catch (TaskCanceledException) { return null; } } /// /// Hält die Session am Leben (Ping). Sollte alle ~60 Sekunden aufgerufen werden. /// public async Task TickleAsync(CancellationToken ct = default) { try { await RateLimitAsync(ct); await _http.PostAsync("tickle", null, ct); } catch (Exception ex) { _logger.Warn("IBKR", $"Tickle fehlgeschlagen: {ex.Message}"); } } /// /// Initialisiert die Brokerage-Session über den Gateway. /// public async Task InitBrokerageSessionAsync(CancellationToken ct = default) { try { await RateLimitAsync(ct); var body = new StringContent("{\"publish\":true,\"compete\":true}", System.Text.Encoding.UTF8, "application/json"); var response = await _http.PostAsync("iserver/auth/ssodh/init", body, ct); return response.IsSuccessStatusCode; } catch (Exception ex) { _logger.Warn("IBKR", $"Brokerage-Session-Init fehlgeschlagen: {ex.Message}"); return false; } } // ─── Contract Search ───────────────────────────────────────────────────── /// /// Sucht einen Contract anhand des Symbols. /// POST /iserver/secdef/search /// Body: {"symbol":"AAPL"} /// public async Task?> SearchContractBySymbolAsync( string symbol, CancellationToken ct = default) { try { await RateLimitAsync(ct); var body = JsonContent.Create(new { symbol }); var response = await _http.PostAsync("iserver/secdef/search", body, ct); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(ct); _logger.Warn("IBKR", $"Contract-Suche für '{symbol}' fehlgeschlagen: HTTP {(int)response.StatusCode} – {errorBody}"); return null; } return await response.Content.ReadFromJsonAsync>(JsonOpts, ct); } catch (Exception ex) { _logger.Error("IBKR", $"Contract-Suche für '{symbol}' fehlgeschlagen: {ex.Message}", ex); return null; } } /// /// Ruft detaillierte Contract-Informationen ab. /// GET /iserver/contract/{conid}/info /// public async Task GetContractInfoAsync( long conid, CancellationToken ct = default) { try { await RateLimitAsync(ct); var response = await _http.GetAsync($"iserver/contract/{conid}/info", ct); if (!response.IsSuccessStatusCode) { _logger.Warn("IBKR", $"Contract-Info für conid {conid} fehlgeschlagen: HTTP {(int)response.StatusCode}"); return null; } return await response.Content.ReadFromJsonAsync(JsonOpts, ct); } catch (Exception ex) { _logger.Error("IBKR", $"Contract-Info für conid {conid} fehlgeschlagen: {ex.Message}", ex); return null; } } /// /// Sucht Aktien-Contracts nach Symbolen. /// GET /trsrv/stocks?symbols=AAPL,MSFT /// public async Task>?> SearchStocksBySymbolAsync( string symbols, CancellationToken ct = default) { try { await RateLimitAsync(ct); var response = await _http.GetAsync($"trsrv/stocks?symbols={Uri.EscapeDataString(symbols)}", ct); if (!response.IsSuccessStatusCode) { _logger.Warn("IBKR", $"Stock-Suche fehlgeschlagen: HTTP {(int)response.StatusCode}"); return null; } return await response.Content.ReadFromJsonAsync>>(JsonOpts, ct); } catch (Exception ex) { _logger.Error("IBKR", $"Stock-Suche fehlgeschlagen: {ex.Message}", ex); return null; } } // ─── Historical Market Data ────────────────────────────────────────────── /// /// Ruft historische Marktdaten ab. /// GET /iserver/marketdata/history?conid={conid}&period={period}&bar={bar} /// /// period: 1d, 1w, 1m, 3m, 6m, 1y, 2y, 5y /// bar: 1min, 5min, 15min, 30min, 1h, 1d, 1w, 1m /// public async Task GetHistoricalDataAsync( long conid, string period = "2y", string bar = "1d", bool outsideRth = false, CancellationToken ct = default) { try { await RateLimitAsync(ct); var url = $"iserver/marketdata/history?conid={conid}" + $"&period={Uri.EscapeDataString(period)}" + $"&bar={Uri.EscapeDataString(bar)}" + $"&outsideRth={outsideRth.ToString().ToLower()}"; var response = await _http.GetAsync(url, ct); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(ct); _logger.Warn("IBKR", $"Historische Daten für conid {conid} fehlgeschlagen: HTTP {(int)response.StatusCode} – {errorBody}"); return null; } return await response.Content.ReadFromJsonAsync(JsonOpts, ct); } catch (Exception ex) { _logger.Error("IBKR", $"Historische Daten für conid {conid} fehlgeschlagen: {ex.Message}", ex); return null; } } // ─── Disconnect ────────────────────────────────────────────────────────── public Task DisconnectAsync() { IsConnected = false; return Task.CompletedTask; } }