using System.Collections.Concurrent; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Settings; namespace IBKRTrader.Core.Trading.Ibkr; /// /// Echter Broker-Adapter über die TWS API (IB Gateway bzw. TWS, Socket-Verbindung). /// /// Wird nur registriert, wenn IBKRSettings.UseTwsApi gesetzt ist – sonst bleibt der /// aktiv. Der globale Handelsschalter /// (TradingSettings.TradingEnabled) bleibt davon unberührt: ohne ihn platziert der /// gar keine Order, egal welcher Broker registriert ist. /// /// Fehler werden nie geworfen, sondern in leere Ergebnisse übersetzt (kein Kurs, Konto 0, /// fehlgeschlagene Order). Ein Konto mit Wert 0 lässt die Risikoprüfung jedes Signal ablehnen – /// die sichere Richtung, wenn der Broker nicht erreichbar ist. /// public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, IBrokerOrderBook, IOptionMarketData, IQuoteSource, IMarketCalendar, IDisposable { private const string LogModule = "IBKR"; private readonly LoggingService _logger; private readonly IbkrConnection? _connection; private readonly TimeSpan _requestTimeout; private readonly TimeSpan _orderTimeout; // Handelszeiten je Instrument, bis zum Ende des gemeldeten Zeitraums gueltig (#505). private readonly ConcurrentDictionary _schedules = new(StringComparer.OrdinalIgnoreCase); public IbkrBrokerClient(SettingsService settings, LoggingService logger) { _logger = logger; var ibkr = settings.Settings.IBKR; var mode = settings.Settings.Trading.ParsedMode; _requestTimeout = TimeSpan.FromSeconds(Math.Max(1, ibkr.RequestTimeoutSeconds)); _orderTimeout = TimeSpan.FromSeconds(Math.Max(1, ibkr.OrderTimeoutSeconds)); // Ein Paper-Modus auf dem Live-Port würde echtes Geld bewegen: dann lieber gar nicht // verbinden, statt auf dem falschen Konto zu handeln. var mismatch = IbkrMapping.ValidatePort(ibkr.Port, mode); if (mismatch is not null) { _logger.Error(LogModule, $"{mismatch} Broker bleibt inaktiv – Port oder Handelsmodus in den Einstellungen korrigieren " + $"(Paper: {IbkrMapping.GatewayPaperPort}, Live: {IbkrMapping.GatewayLivePort})."); return; } _connection = new IbkrConnection( logger, ibkr.Host, ibkr.Port, ibkr.ClientId, ibkr.MarketDataType, TimeSpan.FromSeconds(Math.Max(1, ibkr.ConnectTimeoutSeconds))); _logger.Info(LogModule, $"TWS-Broker aktiv: {ibkr.Host}:{ibkr.Port} (Client {ibkr.ClientId}), Modus {mode}."); } public async Task GetQuoteAsync(InstrumentRef instrument, CancellationToken ct = default) { if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null; var contract = await _connection!.ResolveContractAsync(instrument, _requestTimeout, ct).ConfigureAwait(false); if (contract is null) return null; return await _connection.RequestQuoteAsync(contract, instrument.Symbol, _requestTimeout, ct).ConfigureAwait(false); } public async Task GetAccountStateAsync(CancellationToken ct = default) { if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Empty; return await _connection!.RequestAccountAsync(_requestTimeout, ct).ConfigureAwait(false) ?? Empty; } public async Task PlaceOrderAsync(OrderRequest request, CancellationToken ct = default) { if (!await IsReadyAsync(ct).ConfigureAwait(false)) return OrderResult.Fail("Keine Verbindung zur TWS bzw. zum IB Gateway."); var contract = await _connection!.ResolveContractAsync(request.Instrument, _requestTimeout, ct).ConfigureAwait(false); if (contract is null) return OrderResult.Fail($"Kontrakt für {request.Instrument} nicht auflösbar – Order nicht platziert."); var result = await _connection.PlaceOrderAsync(request, contract, _orderTimeout, ct).ConfigureAwait(false); if (result.Success) _logger.Info(LogModule, $"Order {result.OrderId} ausgeführt: {request.Side} {result.FilledQuantity}x {request.Symbol} " + $"@ {result.AvgFillPrice:F2}."); else if (result.Outcome == OrderOutcome.Working) // Warnung, nicht Fehler: Es ist nichts schiefgegangen. Die Order liegt beim Broker, // und genau das muss sichtbar sein – als Fehler gemeldet, sucht jemand eine Ursache, // die es nicht gibt. _logger.Warn(LogModule, result.Error ?? "Order liegt beim Broker."); else _logger.Error(LogModule, result.Error ?? "Order fehlgeschlagen."); return result; } public async Task> GetPositionsAsync(CancellationToken ct = default) { if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty(); return await _connection!.RequestPositionsAsync(_requestTimeout, ct).ConfigureAwait(false); } public async Task> GetExecutionsAsync(DateTime? since = null, CancellationToken ct = default) { if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty(); return await _connection!.RequestExecutionsAsync(since, _requestTimeout, ct).ConfigureAwait(false); } /// /// Handelszeiten des Instruments, zwischengespeichert bis zum Ende des gemeldeten Zeitraums. /// /// Warum ueberhaupt ein Zwischenspeicher. Ein Modul im 15-Minuten-Takt fragte sonst /// dieselbe Auskunft viermal je Stunde und Instrument neu ab, obwohl sie sich hoechstens /// taeglich aendert. Jede dieser Anfragen belegt einen Antwortplatz beim Broker und kann in die /// Zeitueberschreitung laufen – Aufwand ohne Ertrag. /// /// Fehlschlaege werden nicht gemerkt. Ein null heisst „keine Auskunft" und /// gilt beim Aufrufer als geschlossen. Wuerde das haengenbleiben, bliebe ein Modul nach einer /// einzigen Stoerung bis zum Neustart stumm. /// public async Task GetScheduleAsync(InstrumentRef instrument, CancellationToken ct = default) { var key = instrument.Key; // Gueltig, solange der gemeldete Zeitraum noch in der Zukunft endet. Danach wuerde die // Auskunft still veralten - und "gestern war offen" ist die gefaehrliche Richtung. if (_schedules.TryGetValue(key, out var cached) && cached.Sessions.Count > 0 && cached.Sessions[^1].CloseUtc > DateTime.UtcNow) { return cached; } if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null; var schedule = await _connection!.RequestMarketScheduleAsync(instrument, _requestTimeout, ct) .ConfigureAwait(false); if (schedule is not null) _schedules[key] = schedule; return schedule; } public async Task> GetOpenOrdersAsync(CancellationToken ct = default) { if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty(); return await _connection!.RequestOpenOrdersAsync(_requestTimeout, ct).ConfigureAwait(false); } public async Task CancelOrderAsync(int orderId, CancellationToken ct = default) { if (!await IsReadyAsync(ct).ConfigureAwait(false)) { _logger.Warn(LogModule, $"Order {orderId} nicht storniert: keine Verbindung zum Broker."); return false; } return await _connection!.CancelOrderAsync(orderId, _orderTimeout, ct).ConfigureAwait(false); } private static AccountState Empty => new(0m, 0m); private async Task IsReadyAsync(CancellationToken ct) { if (_connection is null) return false; return await _connection.EnsureConnectedAsync(ct).ConfigureAwait(false); } // ─── Optionsdaten ──────────────────────────────────────────────────────── /// /// Verfallstage und Strikes eines Basiswerts. Wie überall in diesem Adapter wird ein Fehler /// nicht geworfen, sondern zu einer leeren Antwort: Eine fehlende Kette darf kein Modul /// abstürzen lassen, sie darf nur dazu führen, dass es nichts tut. /// public async Task GetChainAsync(InstrumentRef underlying, CancellationToken ct = default) { if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null; return await _connection!.RequestOptionChainAsync(underlying, _requestTimeout, ct).ConfigureAwait(false); } /// Bewertungsgrößen eines Kontrakts; null, wenn der Broker nichts liefert. public async Task GetGreeksAsync(OptionSpec spec, CancellationToken ct = default) { if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null; return await _connection!.RequestGreeksAsync(spec, _requestTimeout, ct).ConfigureAwait(false); } public void Dispose() => _connection?.Dispose(); }