Broker-Adapter gegen TWS/IB Gateway, aktivierbar über IBKRSettings.UseTwsApi;
NullBrokerClient bleibt Default. TradingEnabled bleibt als zweite, unabhängige
Sicherung bestehen – ohne ihn platziert der ExecutionService keine Order.
Aufteilung (src/IBKRTrader.Core/Trading/Ibkr/):
- IbkrMapping – reine Abbildung Core <-> TWS (Kontrakt, Order, Kurs, Port-
und Statusregeln), vollständig unit-getestet
- IbkrConnection – Socket-Lebenszyklus, Reader-Thread, reqId-Korrelation über
TaskCompletionSource
- IbkrBrokerClient – implementiert IBrokerClient, übersetzt Fehler in leere
Ergebnisse (Konto 0 lässt die Risikoprüfung alles ablehnen)
Bewusste Entscheidungen:
- Träges Verbinden mit Wiederholung statt Verbindungsaufbau beim Start: TWS ist
nach einem Neustart minutenlang nicht bereit.
- Port wird gegen den Handelsmodus geprüft; Paper-Modus auf Live-Port (oder
umgekehrt) lässt den Broker inaktiv, statt auf dem falschen Konto zu handeln.
- MarketDataType Default 4: Paper-Konten ohne Datenabo bekommen sonst keine Kurse.
- Fehlercode 10167 ist ein Statushinweis (verzögerte Daten folgen), kein Fehler.
Als Fehler behandelt scheiterte jede einzelne Kursabfrage.
Verifiziert gegen Paper-Konto DUR371528: Verbindung, Konto (100.105,50 EUR),
Kurse (AAPL/MSFT/NVDA, verzögert), Fehlerpfade. Orderpfad bis zur Broker-Annahme
per What-If-Order geprüft (Aktie + Option, ohne Ausführung); dabei zugleich die
Optionsberechtigung des Kontos bestätigt. Offen: echte Ausführung (Fill ->
Buchung) und asynchrone Fill-Verfolgung – beides in IBKR-Integration.md notiert.
Doku: TWS-Setup-Checkliste.md (Einstellungen für Neuinstallation) neu,
IBKR-Integration.md / ARCHITECTURE.md / README.md nachgezogen.
154/154 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
104 lines
4.3 KiB
C#
104 lines
4.3 KiB
C#
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, IDisposable
|
||
{
|
||
private const string LogModule = "IBKR";
|
||
|
||
private readonly LoggingService _logger;
|
||
private readonly IbkrConnection? _connection;
|
||
private readonly TimeSpan _requestTimeout;
|
||
private readonly TimeSpan _orderTimeout;
|
||
|
||
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(string symbol, CancellationToken ct = default)
|
||
{
|
||
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null;
|
||
|
||
var contract = await _connection!.ResolveContractAsync(symbol, _requestTimeout, ct).ConfigureAwait(false);
|
||
if (contract is null) return null;
|
||
|
||
return await _connection.RequestQuoteAsync(contract, 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.Symbol, _requestTimeout, ct).ConfigureAwait(false);
|
||
if (contract is null)
|
||
return OrderResult.Fail($"Kontrakt für {request.Symbol} 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
|
||
_logger.Error(LogModule, result.Error ?? "Order fehlgeschlagen.");
|
||
|
||
return result;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
public void Dispose() => _connection?.Dispose();
|
||
}
|