Files
IBKRTrader/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs
T
RichardandClaude Opus 5 2ca5bc6553 Reconnect laesst keinen Lesethread mehr zurueck (#504, #505)
Nach jedem Verbindungsaufbau lief ein weiterer IBKR-Lesethread mit: Die
Schleifenbedingung "while (_socket.IsConnected())" ist nach einem Reconnect
wieder wahr, weil dasselbe Socket-Objekt neu verbunden wird. Auf der Test-VM
waren es nach zwei TWS-Neustarts drei Threads gleichzeitig.

Alle warten auf demselben EReaderMonitorSignal, und ein Signal weckt genau
einen. Weckt es den falschen, bleibt die Nachricht in der Warteschlange eines
anderen Readers liegen. Bei laufend wiederholten Push-Daten faellt das nicht
auf, die einmalige Antwort auf reqPositions laeuft in die Frist - der Dienst
sieht gesund aus und ist blind. Zeitueberschreitungen je Tag: 1, 3, 215, 96.

Jeder Lesethread bekommt jetzt eine Generationsnummer. Veraltete beenden sich
beim naechsten Aufwachen und reichen das verbrauchte Signal weiter, damit die
Nachricht des Nachfolgers nicht liegen bleibt.

Dazu ein Zwischenspeicher fuer die Handelszeiten (#505): dieselbe Auskunft
wurde viermal je Stunde und Instrument neu angefragt, obwohl sie sich
hoechstens taeglich aendert. Gueltig bis zum Ende des gemeldeten Zeitraums;
Fehlschlaege werden bewusst nicht gemerkt, sonst bliebe ein Modul nach einer
einzigen Stoerung bis zum Neustart stumm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:43:09 +02:00

203 lines
9.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Trading.Ibkr;
/// <summary>
/// Echter Broker-Adapter über die TWS API (IB Gateway bzw. TWS, Socket-Verbindung).
///
/// Wird nur registriert, wenn <c>IBKRSettings.UseTwsApi</c> gesetzt ist – sonst bleibt der
/// <see cref="NullBrokerClient"/> aktiv. Der globale Handelsschalter
/// (<c>TradingSettings.TradingEnabled</c>) bleibt davon unberührt: ohne ihn platziert der
/// <see cref="ExecutionService"/> 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.
/// </summary>
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<string, MarketSchedule> _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<Quote?> 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<AccountState> GetAccountStateAsync(CancellationToken ct = default)
{
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Empty;
return await _connection!.RequestAccountAsync(_requestTimeout, ct).ConfigureAwait(false) ?? Empty;
}
public async Task<OrderResult> 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<IReadOnlyList<BrokerPosition>> GetPositionsAsync(CancellationToken ct = default)
{
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty<BrokerPosition>();
return await _connection!.RequestPositionsAsync(_requestTimeout, ct).ConfigureAwait(false);
}
public async Task<IReadOnlyList<BrokerExecution>> GetExecutionsAsync(DateTime? since = null,
CancellationToken ct = default)
{
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty<BrokerExecution>();
return await _connection!.RequestExecutionsAsync(since, _requestTimeout, ct).ConfigureAwait(false);
}
/// <summary>
/// Handelszeiten des Instruments, zwischengespeichert bis zum Ende des gemeldeten Zeitraums.
///
/// <para><b>Warum ueberhaupt ein Zwischenspeicher.</b> 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.</para>
///
/// <para><b>Fehlschlaege werden nicht gemerkt.</b> Ein <c>null</c> heisst „keine Auskunft" und
/// gilt beim Aufrufer als geschlossen. Wuerde das haengenbleiben, bliebe ein Modul nach einer
/// einzigen Stoerung bis zum Neustart stumm.</para>
/// </summary>
public async Task<MarketSchedule?> 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<IReadOnlyList<BrokerOrder>> GetOpenOrdersAsync(CancellationToken ct = default)
{
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty<BrokerOrder>();
return await _connection!.RequestOpenOrdersAsync(_requestTimeout, ct).ConfigureAwait(false);
}
public async Task<bool> 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<bool> IsReadyAsync(CancellationToken ct)
{
if (_connection is null) return false;
return await _connection.EnsureConnectedAsync(ct).ConfigureAwait(false);
}
// ─── Optionsdaten ────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
public async Task<OptionChain?> GetChainAsync(InstrumentRef underlying, CancellationToken ct = default)
{
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null;
return await _connection!.RequestOptionChainAsync(underlying, _requestTimeout, ct).ConfigureAwait(false);
}
/// <summary>Bewertungsgrößen eines Kontrakts; <c>null</c>, wenn der Broker nichts liefert.</summary>
public async Task<OptionGreeks?> 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();
}