R9: Echter IbkrBrokerClient über die TWS API
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>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user