diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs index ed21b14..aeb73ae 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Settings; @@ -26,6 +27,10 @@ public sealed class IbkrBrokerClient 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; @@ -113,11 +118,38 @@ public sealed class IbkrBrokerClient 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; - return await _connection!.RequestMarketScheduleAsync(instrument, _requestTimeout, ct).ConfigureAwait(false); + 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) diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs index ef1a03d..05175ea 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs @@ -55,6 +55,9 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable private bool _zoneWarningIssued; private volatile bool _ready; private volatile bool _disposed; + // Laufende Nummer des Lesethreads. Jeder Verbindungsaufbau startet einen neuen; die aelteren + // erkennen an dieser Nummer, dass sie nicht mehr zustaendig sind, und beenden sich. + private int _readerGeneration; private int _nextRequestId = 1000; private int _nextOrderId = -1; private string? _account; @@ -235,29 +238,57 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable } } + /// + /// Startet den Lesethread fuer die gerade aufgebaute Verbindung. + /// + /// Warum hier eine Generationsnummer noetig ist. und + /// ueberleben den Reconnect – wir verbinden dasselbe Socket-Objekt neu. + /// Ein Lesethread mit der Bedingung while (_socket.IsConnected()) wuerde deshalb nach + /// einem Reconnect weiterlaufen, denn die Bedingung ist wieder wahr. Nach zwei Nachtneu- + /// starts der TWS liefen am 2026-09-08 drei Lesethreads gleichzeitig (#504). + /// + /// Das ist kein harmloses Leck: Alle warten auf dasselbe + /// , und ein Signal weckt genau einen. Weckt es den falschen, + /// bleibt die Nachricht in der Warteschlange eines anderen Readers liegen, bis zufaellig das + /// naechste Signal kommt. Was der Broker laufend wiederholt (Konto, Kurse), faellt dadurch nicht + /// auf – die einmalige Antwort auf reqPositions oder reqContractDetails laeuft in + /// die Zeitueberschreitung. Der Dienst sieht dann gesund aus und ist blind. + /// private void StartReader() { - var reader = new EReader(_socket, _signal); + var generation = Interlocked.Increment(ref _readerGeneration); + var reader = new EReader(_socket, _signal); reader.Start(); new Thread(() => { - while (_socket.IsConnected()) + while (_socket.IsConnected() && Volatile.Read(ref _readerGeneration) == generation) { _signal.waitForSignal(); + + // Nach dem Aufwachen erneut pruefen: Waehrend wir warteten, kann ein Reconnect + // einen neuen Reader gestartet haben. Dann gehoert diese Nachricht ihm. + if (Volatile.Read(ref _readerGeneration) != generation) + { + // Das verbrauchte Signal weitergeben, sonst bliebe die Nachricht des Nachfolgers + // bis zum naechsten Eintreffen liegen. + _signal.issueSignal(); + break; + } + try { reader.processMsgs(); } catch (Exception ex) { - if (!_disposed) + if (!_disposed && Volatile.Read(ref _readerGeneration) == generation) _logger.Warn(LogModule, $"Nachrichten-Reader beendet: {ex.Message}"); break; } } }) - { IsBackground = true, Name = "IBKR-Reader" }.Start(); + { IsBackground = true, Name = $"IBKR-Reader-{generation}" }.Start(); } private void SafeDisconnect()