Files
IBKRTrader/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs
T
RichardandClaude Opus 5 87194bfc48 IBKR: Zeitkontext einer Verbindung nachvollziehbar machen
ResolveExecutionTime liefert neben dem UTC-Zeitpunkt jetzt die Herkunft der
verwendeten Zeitzone (gemeldet / angenommen / unbekannt / unlesbar). Bisher war
im Nachhinein nicht unterscheidbar, ob ein Buchungszeitpunkt von TWS stammte
oder eine Annahme war - genau der Fehler, der beim Umzug zwischen EU- und
US-Host lautlos entsteht.

IbkrConnection schreibt beim Verbinden einmalig Betriebszeitzone, Systemzeitzone
und den Versatz zur TWS-Serverzeit ins Log; ab 5 s Abweichung gilt die Uhr des
Hosts als verstellt.

TWS-Setup-Checkliste um den Linux-Abschnitt ergaenzt (Betrieb und Umgebung
unterscheiden sich, das Protokoll nicht).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:44:41 +02:00

755 lines
30 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 System.Globalization;
using IBApi;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Time;
namespace IBKRTrader.Core.Trading.Ibkr;
/// <summary>
/// Hält die Socket-Verbindung zur TWS bzw. zum IB Gateway und übersetzt die callback-basierte
/// TWS-API in awaitable Anfragen: jede Anfrage bekommt eine reqId, deren Antworten in einem
/// Slot gesammelt und über einen <see cref="TaskCompletionSource{TResult}"/> aufgelöst werden.
///
/// Verbunden wird träge bei der ersten Anfrage und danach bei Bedarf erneut: TWS ist nach einem
/// Neustart mehrere Minuten nicht bereit (erst abgelehnte Verbindungen, dann abgebrochene
/// Handshakes), ein einmaliger Verbindungsversuch beim Programmstart würde das nicht überleben.
/// </summary>
internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
{
private const string LogModule = "IBKR";
private readonly LoggingService _logger;
private readonly string _host;
private readonly int _port;
private readonly int _clientId;
private readonly int _marketDataType;
private readonly TimeSpan _connectTimeout;
private readonly EReaderMonitorSignal _signal = new();
private readonly EClientSocket _socket;
private readonly SemaphoreSlim _connectLock = new(1, 1);
private readonly ConcurrentDictionary<int, QuoteSlot> _quotes = new();
private readonly ConcurrentDictionary<int, AccountSlot> _accounts = new();
private readonly ConcurrentDictionary<int, ContractSlot> _contracts = new();
private readonly ConcurrentDictionary<int, OrderSlot> _orders = new();
private readonly ConcurrentDictionary<int, ExecutionSlot> _executions = new();
private readonly ConcurrentDictionary<string, Contract> _contractCache = new(StringComparer.OrdinalIgnoreCase);
// Portfolio-Abruf ist ein kontoweites Abonnement, keine reqId-Anfrage daher nur ein Slot.
private PortfolioSlot? _portfolio;
private TaskCompletionSource<bool> _handshake = NewTcs();
private TaskCompletionSource<long>? _serverTime;
/// <summary>Ab diesem Versatz zur TWS-Serverzeit gilt die Uhr des Hosts als verstellt.</summary>
private const double MaxClockSkewSeconds = 5;
// Der Hinweis auf angenommene Zeitzonen soll einmal je Verbindung kommen, nicht je Abruf.
private bool _zoneWarningIssued;
private volatile bool _ready;
private volatile bool _disposed;
private int _nextRequestId = 1000;
private int _nextOrderId = -1;
private string? _account;
public IbkrConnection(LoggingService logger, string host, int port, int clientId,
int marketDataType, TimeSpan connectTimeout)
{
_logger = logger;
_host = host;
_port = port;
_clientId = clientId;
_marketDataType = marketDataType;
_connectTimeout = connectTimeout;
_socket = new EClientSocket(this, _signal);
}
/// <summary>Kontonummer, die TWS beim Verbinden gemeldet hat (z. B. "DUR371528").</summary>
public string? Account => _account;
// ─── Verbindung ───────────────────────────────────────────────────────────
public async Task<bool> EnsureConnectedAsync(CancellationToken ct)
{
if (_disposed) return false;
if (_ready && _socket.IsConnected()) return true;
await _connectLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (_disposed) return false;
if (_ready && _socket.IsConnected()) return true;
return await ConnectAsync(ct).ConfigureAwait(false);
}
finally
{
_connectLock.Release();
}
}
private async Task<bool> ConnectAsync(CancellationToken ct)
{
_ready = false;
_handshake = NewTcs();
SafeDisconnect();
// eConnect blockiert und hängt nach einem TWS-Neustart auch schon mal minutenlang ohne
// Antwort deshalb auf einem Hintergrund-Thread mit Zeitlimit statt direkt.
var connect = Task.Run(() =>
{
try
{
_socket.eConnect(_host, _port, _clientId);
return _socket.IsConnected();
}
catch
{
// Die API meldet Socket-Fehler bereits über error(Exception); hier reicht das Ergebnis.
return false;
}
}, ct);
if (!await WaitAsync(connect, _connectTimeout, ct).ConfigureAwait(false) || !connect.Result)
{
_logger.Warn(LogModule, $"Keine Verbindung zu {_host}:{_port} (Client {_clientId}) läuft TWS/IB Gateway?");
SafeDisconnect();
return false;
}
StartReader();
// Erst nextValidId bestätigt den Handshake; vorher sind keine Anfragen zulässig. Bleibt es
// aus, hat TWS die Verbindung nicht freigegeben (fehlende Trusted IP oder offenes Popup).
if (!await WaitAsync(_handshake.Task, _connectTimeout, ct).ConfigureAwait(false))
{
_logger.Warn(LogModule,
"TWS hat den Handshake nicht bestätigt (kein nextValidId). Trusted IP 127.0.0.1 prüfen " +
"und TWS neu starten siehe docs/TWS-Setup-Checkliste.md.");
SafeDisconnect();
return false;
}
_ready = true;
_zoneWarningIssued = false;
_socket.reqMarketDataType(_marketDataType);
_logger.Info(LogModule,
$"Verbunden mit {_host}:{_port} (Client {_clientId}), Konto {_account ?? "unbekannt"}.");
await LogTimeContextAsync(ct).ConfigureAwait(false);
return true;
}
/// <summary>
/// Schreibt den vollständigen Zeitkontext einer Verbindung ins Log: Betriebszeitzone,
/// Systemzeitzone und den Versatz zur Uhr des TWS-Servers.
///
/// <para><b>Wozu:</b> Wir betreiben Instanzen in EU und US, künftig auf Linux-VMs. Weicht die
/// Betriebszeitzone von der des Hosts ab oder geht die VM-Uhr nach, verschieben sich
/// Buchungszeiten ohne dass irgendwo ein Fehler auftaucht. Steht der Kontext am Anfang jeder
/// Verbindung im Log, lässt sich das im Nachhinein an einer Zeile ablesen statt zu raten.</para>
/// </summary>
private async Task LogTimeContextAsync(CancellationToken ct)
{
var context = $"Zeitkontext: Betriebszeitzone {AppTimeZone.CurrentId}, " +
$"Systemzeitzone {TimeZoneInfo.Local.Id}";
var pending = NewTcs<long>();
_serverTime = pending;
try
{
_socket.reqCurrentTime();
if (!await WaitAsync(pending.Task, TimeSpan.FromSeconds(5), ct).ConfigureAwait(false))
{
_logger.Info(LogModule, context + ", TWS-Serverzeit nicht ermittelbar.");
return;
}
var serverUtc = DateTimeOffset.FromUnixTimeSeconds(pending.Task.Result).UtcDateTime;
var skew = (serverUtc - DateTime.UtcNow).TotalSeconds;
context += $", TWS-Serverzeit {serverUtc:yyyy-MM-dd HH:mm:ss}Z, " +
$"Uhrenversatz {skew.ToString("+0.0;-0.0;0", CultureInfo.InvariantCulture)} s";
if (Math.Abs(skew) > MaxClockSkewSeconds)
_logger.Warn(LogModule, context +
" die Uhren laufen auseinander. In virtuellen Maschinen ist das ein häufiger " +
"Fehler; die Zeitsynchronisation des Hosts prüfen, sonst wandern Buchungszeiten.");
else
_logger.Info(LogModule, context + ".");
}
finally
{
_serverTime = null;
}
}
/// <summary>
/// Fasst nach jedem Abruf zusammen, wie viele Zeitstempel TWS mit Zonenangabe gemeldet hat und
/// wie viele über die Betriebszeitzone <b>angenommen</b> wurden. Die angenommenen sind die
/// Stelle, an der ein Wechsel zwischen EU- und US-Host lautlos danebenliegt.
/// </summary>
private void LogTimeProvenance(IReadOnlyList<IbkrMapping.ExecutionTimestamp> stamps)
{
if (stamps.Count == 0) return;
var reported = stamps.Count(t => t.Source == IbkrMapping.ExecutionTimeSource.ReportedZone);
var assumed = stamps.Count(t => t.IsAssumed);
var unreadable = stamps.Count(t => t.Source == IbkrMapping.ExecutionTimeSource.Unparsable);
var zones = string.Join(", ", stamps.Where(t => t.ReportedZone is not null)
.Select(t => t.ReportedZone!)
.Distinct());
var text = $"Zeitstempel von {stamps.Count} Ausführung(en): {reported} mit gemeldeter Zone" +
(zones.Length > 0 ? $" ({zones})" : "") +
$", {assumed} über die Betriebszeitzone {AppTimeZone.CurrentId}" +
(unreadable > 0 ? $", {unreadable} unlesbar" : "") + ".";
_logger.Info(LogModule, text);
// Unlesbare Zeitstempel sind immer ein Defekt die Ausführung landet sonst auf DateTime.MinValue.
if (unreadable > 0)
_logger.Warn(LogModule,
$"{unreadable} Ausführung(en) mit unlesbarem Zeitstempel das Format von TWS hat sich " +
"vermutlich geändert. IbkrMapping.ResolveExecutionTime prüfen.");
// Angenommene Zonen sind nur dann heikel, wenn Betriebs- und Systemzeitzone auseinandergehen:
// dann ist nicht mehr offensichtlich, gegen welche Uhr TWS die Zeit gemeldet hat.
if (assumed > 0 && !_zoneWarningIssued &&
!string.Equals(AppTimeZone.CurrentId, TimeZoneInfo.Local.Id, StringComparison.OrdinalIgnoreCase))
{
_zoneWarningIssued = true;
_logger.Warn(LogModule,
$"{assumed} Zeitstempel ohne Zonenangabe wurden gegen die Betriebszeitzone " +
$"{AppTimeZone.CurrentId} gerechnet, das System läuft aber auf {TimeZoneInfo.Local.Id}. " +
"Stimmt Trading.ApplicationTimeZoneId nicht mit der Zeitzone des TWS-Hosts überein, " +
"liegen die Buchungszeiten daneben. Einmal gegen TWS gegenprüfen.");
}
}
private void StartReader()
{
var reader = new EReader(_socket, _signal);
reader.Start();
new Thread(() =>
{
while (_socket.IsConnected())
{
_signal.waitForSignal();
try
{
reader.processMsgs();
}
catch (Exception ex)
{
if (!_disposed)
_logger.Warn(LogModule, $"Nachrichten-Reader beendet: {ex.Message}");
break;
}
}
})
{ IsBackground = true, Name = "IBKR-Reader" }.Start();
}
private void SafeDisconnect()
{
try { _socket.eDisconnect(); }
catch { /* Socket war nie offen oder ist bereits zu. */ }
}
// ─── Anfragen ─────────────────────────────────────────────────────────────
/// <summary>
/// Löst ein Symbol in einen vollständigen Kontrakt auf (inklusive ConId, damit Kurse und
/// Orders dasselbe Instrument treffen). Ergebnisse werden für die Laufzeit zwischengespeichert.
/// </summary>
public async Task<Contract?> ResolveContractAsync(string symbol, TimeSpan timeout, CancellationToken ct)
{
if (_contractCache.TryGetValue(symbol, out var cached)) return cached;
var id = NextRequestId();
var slot = new ContractSlot();
_contracts[id] = slot;
try
{
_socket.reqContractDetails(id, IbkrMapping.Stock(symbol));
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
{
_logger.Warn(LogModule, $"Zeitüberschreitung bei der Kontraktsuche für {symbol}.");
return null;
}
if (slot.Error is not null)
{
_logger.Warn(LogModule, $"Kontrakt {symbol} nicht auflösbar: {slot.Error}");
return null;
}
if (slot.First is null)
{
_logger.Warn(LogModule, $"Kein Kontrakt für {symbol} gefunden.");
return null;
}
_contractCache[symbol] = slot.First;
return slot.First;
}
finally
{
_contracts.TryRemove(id, out _);
}
}
/// <summary>
/// Momentaufnahme des Kurses. Eine Zeitüberschreitung ist hier kein Fehler: TWS beendet den
/// Snapshot nicht immer sauber, die bis dahin gelieferten Ticks reichen meist für einen Kurs.
/// </summary>
public async Task<Quote?> RequestQuoteAsync(Contract contract, string symbol, TimeSpan timeout, CancellationToken ct)
{
var id = NextRequestId();
var slot = new QuoteSlot();
_quotes[id] = slot;
try
{
_socket.reqMktData(id, contract, "", true, false, null);
await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false);
if (slot.Error is not null)
_logger.Warn(LogModule, $"Kursabfrage {symbol}: {slot.Error}");
return IbkrMapping.BuildQuote(symbol, slot.Last, slot.Bid, slot.Ask, slot.Close);
}
finally
{
_quotes.TryRemove(id, out _);
}
}
public async Task<AccountState?> RequestAccountAsync(TimeSpan timeout, CancellationToken ct)
{
var id = NextRequestId();
var slot = new AccountSlot();
_accounts[id] = slot;
try
{
_socket.reqAccountSummary(id, "All", "NetLiquidation,AvailableFunds");
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
{
_logger.Warn(LogModule, "Zeitüberschreitung bei der Kontoabfrage.");
return null;
}
if (slot.Error is not null)
{
_logger.Warn(LogModule, $"Kontoabfrage fehlgeschlagen: {slot.Error}");
return null;
}
return new AccountState(
ReadDecimal(slot.Values, "NetLiquidation"),
ReadDecimal(slot.Values, "AvailableFunds"));
}
finally
{
_accounts.TryRemove(id, out _);
try { _socket.cancelAccountSummary(id); }
catch { /* Verbindung bereits weg. */ }
}
}
/// <summary>
/// Bestand samt Bewertung. Nutzt <c>reqAccountUpdates</c> statt <c>reqPositions</c>, weil nur
/// dieser Weg Marktwert und unrealisierten G/V mitliefert. Es ist ein Abonnement wir melden
/// uns nach dem ersten vollständigen Stand wieder ab.
/// </summary>
public async Task<IReadOnlyList<BrokerPosition>> RequestPositionsAsync(TimeSpan timeout, CancellationToken ct)
{
var account = _account;
if (string.IsNullOrWhiteSpace(account))
{
_logger.Warn(LogModule, "Kontonummer unbekannt Positionen nicht abrufbar.");
return Array.Empty<BrokerPosition>();
}
var slot = new PortfolioSlot();
if (Interlocked.CompareExchange(ref _portfolio, slot, null) is not null)
{
_logger.Warn(LogModule, "Es läuft bereits eine Positionsabfrage.");
return Array.Empty<BrokerPosition>();
}
try
{
_socket.reqAccountUpdates(true, account);
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
{
_logger.Warn(LogModule, "Zeitüberschreitung bei der Positionsabfrage.");
return Array.Empty<BrokerPosition>();
}
return slot.Positions;
}
finally
{
try { _socket.reqAccountUpdates(false, account); }
catch { /* Verbindung bereits weg. */ }
Interlocked.Exchange(ref _portfolio, null);
}
}
/// <summary>
/// Ausführungen. Kommissionen kommen über einen eigenen Callback und treffen oft erst nach
/// <c>execDetailsEnd</c> ein deshalb die kurze Nachlauffrist, bevor zusammengeführt wird.
/// </summary>
public async Task<IReadOnlyList<BrokerExecution>> RequestExecutionsAsync(DateTime? since,
TimeSpan timeout, CancellationToken ct)
{
var id = NextRequestId();
var slot = new ExecutionSlot();
_executions[id] = slot;
try
{
var filter = new ExecutionFilter();
if (since is { } from) filter.Time = IbkrMapping.FormatExecutionFilterTime(from);
_socket.reqExecutions(id, filter);
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
{
_logger.Warn(LogModule, "Zeitüberschreitung bei der Abfrage der Ausführungen.");
return Array.Empty<BrokerExecution>();
}
await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false);
LogTimeProvenance(slot.Timestamps);
return slot.Items
.Select(e => slot.Commissions.TryGetValue(e.ExecId, out var c)
? e with { Commission = c.Amount, CommissionCurrency = c.Currency }
: e)
.OrderByDescending(e => e.Time)
.ToList();
}
finally
{
_executions.TryRemove(id, out _);
}
}
public async Task<OrderResult> PlaceOrderAsync(OrderRequest request, Contract contract,
TimeSpan timeout, CancellationToken ct)
{
if (request.Type == OrderType.Limit && request.LimitPrice is null or <= 0)
return OrderResult.Fail($"Limit-Order für {request.Symbol} ohne gültigen Limitpreis nicht platziert.");
var orderId = NextOrderId();
if (orderId < 0)
return OrderResult.Fail("Keine gültige Order-ID von TWS erhalten Verbindung nicht bereit.");
var slot = new OrderSlot();
_orders[orderId] = slot;
try
{
_socket.placeOrder(orderId, contract, IbkrMapping.BuildOrder(request, orderId, _account));
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
{
// Die Order ist übermittelt und liegt womöglich aktiv bei IBKR. Sie hier als
// reinen Fehlschlag zu buchen wäre falsch deshalb Order-ID und letzter Status
// in die Meldung, damit der Betreiber sie in TWS wiederfindet.
var status = string.IsNullOrEmpty(slot.Status) ? "keine Rückmeldung" : slot.Status;
return OrderResult.Fail(
$"Order {orderId} ({request.Side} {request.Quantity}x {request.Symbol}) wurde übermittelt, " +
$"blieb aber {timeout.TotalSeconds:F0}s ohne Endstatus (zuletzt: {status}). " +
"Sie kann bei IBKR weiterhin aktiv sein und muss dort geprüft werden.");
}
if (slot.Error is not null)
return OrderResult.Fail($"Order {orderId} abgelehnt: {slot.Error}");
if (slot.Status == "Filled" && slot.Filled > 0)
return OrderResult.Filled(orderId.ToString(CultureInfo.InvariantCulture),
slot.Filled, (decimal)slot.AvgFillPrice);
return OrderResult.Fail($"Order {orderId} endete ohne Ausführung (Status {slot.Status}).");
}
finally
{
_orders.TryRemove(orderId, out _);
}
}
// ─── EWrapper-Callbacks (laufen auf dem Reader-Thread) ────────────────────
public override void nextValidId(int orderId)
{
Interlocked.Exchange(ref _nextOrderId, orderId);
_handshake.TrySetResult(true);
}
public override void managedAccounts(string accountsList) =>
_account = accountsList?.Split(',').FirstOrDefault(a => !string.IsNullOrWhiteSpace(a))?.Trim();
public override void contractDetails(int reqId, ContractDetails contractDetails)
{
if (_contracts.TryGetValue(reqId, out var slot))
slot.First ??= contractDetails.Contract;
}
public override void contractDetailsEnd(int reqId)
{
if (_contracts.TryGetValue(reqId, out var slot)) slot.Complete();
}
public override void tickPrice(int tickerId, int field, double price, TickAttrib attribs)
{
if (price <= 0 || !_quotes.TryGetValue(tickerId, out var slot)) return;
if (IbkrMapping.IsLastTick(field)) slot.Last = price;
else if (IbkrMapping.IsBidTick(field)) slot.Bid = price;
else if (IbkrMapping.IsAskTick(field)) slot.Ask = price;
else if (IbkrMapping.IsCloseTick(field)) slot.Close = price;
}
public override void tickSnapshotEnd(int tickerId)
{
if (_quotes.TryGetValue(tickerId, out var slot)) slot.Complete();
}
public override void accountSummary(int reqId, string account, string tag, string value, string currency)
{
if (_accounts.TryGetValue(reqId, out var slot)) slot.Values[tag] = value;
}
public override void accountSummaryEnd(int reqId)
{
if (_accounts.TryGetValue(reqId, out var slot)) slot.Complete();
}
public override void updatePortfolio(Contract contract, double position, double marketPrice,
double marketValue, double averageCost, double unrealizedPNL, double realizedPNL, string accountName)
{
// Glattgestellte Positionen meldet TWS mit Menge 0 weiter die gehören nicht in den Bestand.
if (_portfolio is not { } slot || position == 0) return;
slot.Positions.Add(new BrokerPosition
{
Symbol = contract.Symbol,
SecType = contract.SecType,
Currency = contract.Currency,
ConId = contract.ConId,
Quantity = (decimal)position,
AvgCost = (decimal)averageCost,
MarketPrice = (decimal)marketPrice,
MarketValue = (decimal)marketValue,
UnrealizedPnl = (decimal)unrealizedPNL
});
}
public override void accountDownloadEnd(string account) => _portfolio?.Complete();
/// <summary>Antwort auf <c>reqCurrentTime</c> Sekunden seit Epoch, Basis des Uhrenvergleichs.</summary>
public override void currentTime(long time) => _serverTime?.TrySetResult(time);
public override void execDetails(int reqId, Contract contract, Execution execution)
{
if (!_executions.TryGetValue(reqId, out var slot)) return;
var stamp = IbkrMapping.ResolveExecutionTime(execution.Time, AppTimeZone.Current);
slot.Timestamps.Add(stamp);
slot.Items.Add(new BrokerExecution
{
ExecId = execution.ExecId,
Time = stamp.Utc ?? DateTime.MinValue,
Symbol = contract.Symbol,
SecType = contract.SecType,
Side = IbkrMapping.ParseSide(execution.Side),
Quantity = (decimal)execution.Shares,
Price = (decimal)execution.Price,
Exchange = execution.Exchange ?? "",
OrderId = execution.OrderId
});
}
public override void execDetailsEnd(int reqId)
{
if (_executions.TryGetValue(reqId, out var slot)) slot.Complete();
}
public override void commissionReport(CommissionReport report)
{
// Der Callback trägt keine reqId; die ExecId ordnet ihn der Ausführung zu.
if (report.Commission is <= 0 or >= 1e100) return;
foreach (var slot in _executions.Values)
slot.Commissions[report.ExecId] = ((decimal)report.Commission, report.Currency ?? "");
}
public override void orderStatus(int orderId, string status, double filled, double remaining,
double avgFillPrice, int permId, int parentId, double lastFillPrice, int clientId,
string whyHeld, double mktCapPrice)
{
if (!_orders.TryGetValue(orderId, out var slot)) return;
slot.Status = status;
slot.Filled = (int)filled;
slot.AvgFillPrice = avgFillPrice;
if (IbkrMapping.IsTerminalStatus(status)) slot.Complete();
}
public override void error(int id, int errorCode, string errorMsg)
{
if (IbkrMapping.IsInformational(errorCode))
{
_logger.Info(LogModule, $"[{errorCode}] {errorMsg}");
return;
}
var text = $"[{errorCode}] {errorMsg}";
if (id >= 0 && FailPending(id, text)) return;
_logger.Warn(LogModule, text);
}
public override void error(Exception e)
{
// Ein abgelehnter Socket heißt schlicht: TWS läuft (noch) nicht. Das ist beim trägen
// Verbinden der Normalfall und wird von ConnectAsync bereits gemeldet kein Stacktrace.
if (e is System.Net.Sockets.SocketException)
{
_logger.Info(LogModule, $"Socket nicht erreichbar: {e.Message}");
return;
}
_logger.Error(LogModule, "API-Ausnahme", e);
}
public override void error(string str) => _logger.Error(LogModule, str);
public override void connectionClosed()
{
_ready = false;
FailAllPending("Verbindung zur TWS wurde geschlossen.");
if (!_disposed) _logger.Warn(LogModule, "Verbindung zur TWS wurde geschlossen.");
}
// ─── Hilfsmittel ──────────────────────────────────────────────────────────
private int NextRequestId() => Interlocked.Increment(ref _nextRequestId);
/// <summary>
/// Vergibt die nächste Order-ID. Die erste Vergabe liefert genau die von TWS über
/// nextValidId gemeldete ID; ohne Handshake bleibt der Wert negativ und damit ungültig.
/// </summary>
private int NextOrderId() => Interlocked.Increment(ref _nextOrderId) - 1;
private bool FailPending(int id, string error)
{
if (_quotes .TryGetValue(id, out var q)) { q.Fail(error); return true; }
if (_accounts .TryGetValue(id, out var a)) { a.Fail(error); return true; }
if (_contracts .TryGetValue(id, out var c)) { c.Fail(error); return true; }
if (_orders .TryGetValue(id, out var o)) { o.Fail(error); return true; }
if (_executions.TryGetValue(id, out var e)) { e.Fail(error); return true; }
return false;
}
private void FailAllPending(string error)
{
foreach (var slot in _quotes.Values) slot.Fail(error);
foreach (var slot in _accounts.Values) slot.Fail(error);
foreach (var slot in _contracts.Values) slot.Fail(error);
foreach (var slot in _orders.Values) slot.Fail(error);
foreach (var slot in _executions.Values) slot.Fail(error);
_portfolio?.Fail(error);
}
private static decimal ReadDecimal(IReadOnlyDictionary<string, string> values, string tag) =>
values.TryGetValue(tag, out var raw) &&
decimal.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed)
? parsed
: 0m;
private static TaskCompletionSource<bool> NewTcs() => NewTcs<bool>();
private static TaskCompletionSource<T> NewTcs<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
private static async Task<bool> WaitAsync(Task task, TimeSpan timeout, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var finished = await Task.WhenAny(task, Task.Delay(timeout, cts.Token)).ConfigureAwait(false);
cts.Cancel(); // beendet den Verzögerungs-Task, wenn die Antwort zuerst da war
return finished == task;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_ready = false;
FailAllPending("Broker-Verbindung wird beendet.");
SafeDisconnect();
_connectLock.Dispose();
}
// ─── Antwort-Slots ────────────────────────────────────────────────────────
private abstract class Slot
{
public readonly TaskCompletionSource<bool> Done = NewTcs();
public string? Error;
public void Complete() => Done.TrySetResult(true);
public void Fail(string error)
{
Error = error;
Done.TrySetResult(false);
}
}
private sealed class QuoteSlot : Slot
{
public double Last, Bid, Ask, Close;
}
private sealed class AccountSlot : Slot
{
public readonly Dictionary<string, string> Values = new(StringComparer.OrdinalIgnoreCase);
}
private sealed class ContractSlot : Slot
{
public Contract? First;
}
private sealed class OrderSlot : Slot
{
public string Status = "";
public int Filled;
public double AvgFillPrice;
}
private sealed class PortfolioSlot : Slot
{
public readonly List<BrokerPosition> Positions = new();
}
private sealed class ExecutionSlot : Slot
{
public readonly List<BrokerExecution> Items = new();
public readonly ConcurrentDictionary<string, (decimal Amount, string Currency)> Commissions = new();
// Herkunft der Zeitangaben, damit nach dem Abruf zusammengefasst werden kann, wie viele
// Zeitpunkte TWS gemeldet und wie viele wir angenommen haben.
public readonly List<IbkrMapping.ExecutionTimestamp> Timestamps = new();
}
}