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:
Richard
2026-08-04 17:40:00 +02:00
co-authored by Claude Opus 5
parent 2a312ca035
commit 80afcd49c1
14 changed files with 1427 additions and 45 deletions
@@ -0,0 +1,198 @@
using FluentAssertions;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Trading.Ibkr;
namespace IBKRTrader.Tests.Trading;
/// <summary>
/// Prüft die Abbildung zwischen Core-Modellen und TWS-API der Teil des Broker-Adapters,
/// der ohne laufenden Gateway testbar ist. Verbindung, Kurse und Orders bleiben manuelle
/// Verifikation gegen das Paper-Konto (siehe docs/TWS-Setup-Checkliste.md).
/// </summary>
[Trait("cat", "unit")]
public class IbkrMappingTests
{
// ─── Port-Validierung (Schutz vor Handel auf dem falschen Konto) ──────────
[Theory]
[InlineData(4001)] // IB Gateway Live
[InlineData(7496)] // TWS Live
public void PaperMode_OnLivePort_IsRejected(int port)
{
var problem = IbkrMapping.ValidatePort(port, TradingMode.Paper);
problem.Should().NotBeNull().And.Contain("LIVE");
}
[Theory]
[InlineData(4002)] // IB Gateway Paper
[InlineData(7497)] // TWS Paper
public void LiveMode_OnPaperPort_IsRejected(int port)
{
var problem = IbkrMapping.ValidatePort(port, TradingMode.Live);
problem.Should().NotBeNull().And.Contain("Paper-Port");
}
[Theory]
[InlineData(4002, TradingMode.Paper)]
[InlineData(7497, TradingMode.Paper)]
[InlineData(4001, TradingMode.Live)]
[InlineData(7496, TradingMode.Live)]
public void MatchingPortAndMode_IsAccepted(int port, TradingMode mode) =>
IbkrMapping.ValidatePort(port, mode).Should().BeNull();
[Fact]
public void NonStandardPort_IsAccepted_ForAnyMode()
{
IbkrMapping.ValidatePort(4999, TradingMode.Paper).Should().BeNull();
IbkrMapping.ValidatePort(4999, TradingMode.Live).Should().BeNull();
}
// ─── Kontrakt ─────────────────────────────────────────────────────────────
[Fact]
public void Stock_BuildsSmartRoutedUsEquity()
{
var contract = IbkrMapping.Stock(" aapl ");
contract.Symbol.Should().Be("AAPL");
contract.SecType.Should().Be("STK");
contract.Exchange.Should().Be("SMART");
contract.Currency.Should().Be("USD");
}
// ─── Order ────────────────────────────────────────────────────────────────
[Fact]
public void BuildOrder_MarketBuy_MapsToMktWithoutLimit()
{
var request = new OrderRequest
{
Symbol = "AAPL", Side = TradeSide.Buy, Quantity = 10, Type = OrderType.Market
};
var order = IbkrMapping.BuildOrder(request, orderId: 42, account: "DUR371528");
order.OrderId.Should().Be(42);
order.Action.Should().Be("BUY");
order.OrderType.Should().Be("MKT");
order.TotalQuantity.Should().Be(10);
// double.MaxValue ist der „nicht gesetzt"-Marker der TWS-API. Eine 0 wäre hier ein Fehler:
// TWS würde sie als echten Limitpreis von 0 lesen.
order.LmtPrice.Should().Be(double.MaxValue);
order.Tif.Should().Be("DAY");
order.Transmit.Should().BeTrue();
order.Account.Should().Be("DUR371528");
}
[Fact]
public void BuildOrder_LimitSell_CarriesLimitPrice()
{
var request = new OrderRequest
{
Symbol = "MSFT", Side = TradeSide.Sell, Quantity = 5,
Type = OrderType.Limit, LimitPrice = 123.45m
};
var order = IbkrMapping.BuildOrder(request, orderId: 7, account: null);
order.Action.Should().Be("SELL");
order.OrderType.Should().Be("LMT");
order.LmtPrice.Should().Be(123.45);
order.Account.Should().BeNullOrEmpty();
}
[Fact]
public void BuildOrder_LimitWithoutPrice_LeavesPriceUnset()
{
// Eine 0 würde TWS als Limitpreis von 0 lesen; der Marker double.MaxValue heißt „nicht gesetzt".
var request = new OrderRequest
{
Symbol = "MSFT", Side = TradeSide.Buy, Quantity = 1, Type = OrderType.Limit, LimitPrice = null
};
IbkrMapping.BuildOrder(request, orderId: 1, account: null)
.LmtPrice.Should().Be(double.MaxValue);
}
// ─── Kurs-Ableitung ───────────────────────────────────────────────────────
[Fact]
public void BuildQuote_PrefersLastTradedPrice()
{
var quote = IbkrMapping.BuildQuote("AAPL", last: 100, bid: 98, ask: 102, close: 95);
quote!.Last.Should().Be(100m);
quote.Bid.Should().Be(98m);
quote.Ask.Should().Be(102m);
}
[Fact]
public void BuildQuote_WithoutLast_UsesBidAskMid()
{
var quote = IbkrMapping.BuildQuote("AAPL", last: 0, bid: 98, ask: 102, close: 95);
quote!.Last.Should().Be(100m);
}
[Fact]
public void BuildQuote_WithOnlyClose_UsesClose()
{
// Paper-Konten ohne Datenabo liefern außerhalb der Handelszeiten oft nur den Schlusskurs.
var quote = IbkrMapping.BuildQuote("AAPL", last: 0, bid: 0, ask: 0, close: 95);
quote!.Last.Should().Be(95m);
quote.Bid.Should().Be(95m);
quote.Ask.Should().Be(95m);
}
[Fact]
public void BuildQuote_WithoutAnyPrice_ReturnsNull() =>
IbkrMapping.BuildQuote("AAPL", last: 0, bid: 0, ask: 0, close: 0).Should().BeNull();
// ─── Tick- und Status-Klassifizierung ─────────────────────────────────────
[Theory]
[InlineData(4, true)] // LAST
[InlineData(68, true)] // DELAYED_LAST
[InlineData(1, false)] // BID
public void IsLastTick_CoversRealtimeAndDelayed(int field, bool expected) =>
IbkrMapping.IsLastTick(field).Should().Be(expected);
[Theory]
[InlineData(1, true)] // BID
[InlineData(66, true)] // DELAYED_BID
[InlineData(2, false)] // ASK
public void IsBidTick_CoversRealtimeAndDelayed(int field, bool expected) =>
IbkrMapping.IsBidTick(field).Should().Be(expected);
[Theory]
[InlineData("Filled", true)]
[InlineData("Cancelled", true)]
[InlineData("ApiCancelled", true)]
[InlineData("Inactive", true)]
[InlineData("Submitted", false)]
[InlineData("PreSubmitted", false)]
public void IsTerminalStatus_OnlyForFinalStates(string status, bool expected) =>
IbkrMapping.IsTerminalStatus(status).Should().Be(expected);
[Theory]
[InlineData(2104, true)] // Marktdatenzentrum OK
[InlineData(2106, true)] // HMDS-Datenzentrum OK
[InlineData(2158, true)] // Sec-def-Datenzentrum OK
[InlineData(1100, true)] // Verbindung verloren
[InlineData(200, false)] // Kontrakt nicht gefunden
[InlineData(201, false)] // Order abgelehnt
[InlineData(354, false)] // Marktdaten nicht abonniert
public void IsInformational_SeparatesStatusFromRealErrors(int code, bool expected) =>
IbkrMapping.IsInformational(code).Should().Be(expected);
[Fact]
public void IsInformational_TreatsDelayedDataNoticeAsStatus()
{
// 10167 kündigt verzögerte Kurse an die Ticks folgen danach noch. Als Fehler behandelt
// scheiterte auf einem Paper-Konto ohne Datenabo jede einzelne Kursabfrage.
IbkrMapping.IsInformational(IbkrMapping.DelayedDataNotice).Should().BeTrue();
}
}