Eigener Seam neben IBrokerClient: Wer handelt, braucht ihn nicht; wer die eigene Buchfuehrung gegen den Broker abstimmt, braucht nur ihn. Zuteilung und Verfall aendern Positionen ohne Order von uns - ohne Abgleich laeuft das Managementbuch zwangslaeufig auseinander. - IBrokerPortfolioReader mit GetPositionsAsync/GetExecutionsAsync; implementiert von IbkrBrokerClient und NullBrokerClient (DI registriert beide Rollen auf derselben Instanz). - IbkrConnection: reqAccountUpdates statt reqPositions (nur dieser Weg liefert Marktwert und unrealisierten G/V), reqExecutions inkl. Zuordnung der verspaetet eintreffenden commissionReport-Callbacks ueber die ExecId. - BrokerPosition/BrokerExecution als Broker-Wahrheit neben Position; IbkrMapping: ParseSide, ParseExecutionTime, FormatExecutionFilterTime (UTC wegen TWS-Warnung 2174) - mit Unit-Tests. - Verifiziert gegen Paper-Konto DUR371528: 2 Positionen, 2 Ausfuehrungen inkl. Kommissionen. Doku: Kapital- und Buchmodell (drei Wahrheiten, Kapitalzuteilung), KONZEPT-Datenlage-und-Strategien (gemessen, was die API auf diesem Konto liefert). Options-Wheel: Greeks bei verzoegerten Daten funktionieren (Feld 83); Earnings-Termine sind ueber die TWS API nicht erreichbar (Fehler 10358) - Behelf ueber IV-Filter statt Fremddatenquelle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
119 lines
5.0 KiB
C#
119 lines
5.0 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, IBrokerPortfolioReader, 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;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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();
|
||
}
|