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(); // ─── Ausführungen ───────────────────────────────────────────────────────── [Theory] [InlineData("BOT", TradeSide.Buy)] [InlineData("SLD", TradeSide.Sell)] [InlineData("sld", TradeSide.Sell)] [InlineData("SELL", TradeSide.Sell)] public void ParseSide_MapsTwsExecutionSides(string raw, TradeSide expected) => IbkrMapping.ParseSide(raw).Should().Be(expected); // Die beiden Zonen, in denen wir Instanzen betreiben. private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); [Fact] public void ParseExecutionTime_OhneZone_RechnetGegenDieBetriebszeitzone() { // So liefert TWS es bei execDetails (doppeltes Leerzeichen, keine Zonenangabe). // 17:39:18 Berliner Sommerzeit (UTC+2) = 15:39:18 UTC. var parsed = IbkrMapping.ParseExecutionTime("20260804 17:39:18", Berlin); parsed.Should().Be(new DateTime(2026, 8, 4, 15, 39, 18, DateTimeKind.Utc)); parsed!.Value.Kind.Should().Be(DateTimeKind.Utc); } [Fact] public void ParseExecutionTime_NutztDieGemeldeteZone_StattSieZuVerwerfen() { // Der Kern der Sache: eine US-Instanz darf eine NYSE-Ausfuehrung nicht als Berliner Zeit // buchen. Gemeldet wird Europe/Berlin, also gilt die - unabhaengig von der Instanzzone. var parsed = IbkrMapping.ParseExecutionTime("20260804 17:52:56 Europe/Berlin", NewYork); parsed.Should().Be(new DateTime(2026, 8, 4, 15, 52, 56, DateTimeKind.Utc)); } [Fact] public void ParseExecutionTime_UsBoerse_LiefertKorrektesUtc() { // 09:30 New Yorker Sommerzeit (UTC-4) = 13:30 UTC - Handelsbeginn NYSE. IbkrMapping.ParseExecutionTime("20260804 09:30:00 America/New_York", Berlin) .Should().Be(new DateTime(2026, 8, 4, 13, 30, 0, DateTimeKind.Utc)); } [Fact] public void ParseExecutionTime_UnbekannteZone_WeichtAufDieBetriebszeitzoneAus() { // Lieber die Instanzzone als gar kein Zeitstempel - die Ausfuehrung darf nicht verlorengehen. IbkrMapping.ParseExecutionTime("20260804 17:39:18 Gibt/EsNicht", Berlin) .Should().Be(new DateTime(2026, 8, 4, 15, 39, 18, DateTimeKind.Utc)); } [Fact] public void ParseExecutionTime_AkzeptiertAuchDasBindestrichFormat() { // Dasselbe Format, das FormatExecutionFilterTime erzeugt. IbkrMapping.ParseExecutionTime("20260804-17:39:18", Berlin) .Should().Be(new DateTime(2026, 8, 4, 15, 39, 18, DateTimeKind.Utc)); } [Fact] public void ParseExecutionTime_Winterzeit_NutztDenRichtigenVersatz() { // Januar: Berlin ist UTC+1, nicht UTC+2. Faengt ein fest verdrahtetes Offset ab. IbkrMapping.ParseExecutionTime("20260115 17:39:18", Berlin) .Should().Be(new DateTime(2026, 1, 15, 16, 39, 18, DateTimeKind.Utc)); } [Theory] [InlineData("")] [InlineData(" ")] [InlineData("20260804")] [InlineData("Unsinn")] public void ParseExecutionTime_ReturnsNullForUnusableInput(string raw) => IbkrMapping.ParseExecutionTime(raw, Berlin).Should().BeNull(); [Fact] public void FormatExecutionFilterTime_UsesUtcWithExplicitFormat() { // TWS warnt (2174) bei Zeitangaben ohne Zeitzone und entfernt das Format künftig. var since = new DateTime(2026, 8, 4, 12, 0, 0, DateTimeKind.Utc); IbkrMapping.FormatExecutionFilterTime(since).Should().Be("20260804-12:00:00"); } // ─── 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(); } }