.NET WinForms-Anwendung (Core, Modules/CongressTrading, UI). Enthaelt .gitignore und settings.example.json als Konfigurationsvorlage. Echte settings.json mit Zugangsdaten ist bewusst ausgeschlossen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
275 lines
9.7 KiB
C#
275 lines
9.7 KiB
C#
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;
|
||
}
|
||
}
|