using FluentAssertions; using IBKRTrader.Core.Trading; using IBKRTrader.Core.Trading.Ibkr; namespace IBKRTrader.Tests.Trading; /// /// 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). /// [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(); } }