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();
}