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");
}
[Fact]
public void Option_BuildsSmartRoutedContract_WithFullExpiryDate()
{
var spec = new OptionSpec("aapl", new DateOnly(2026, 9, 18), 150m, OptionRight.Call);
var contract = IbkrMapping.Option(spec);
contract.Symbol.Should().Be("AAPL");
contract.SecType.Should().Be("OPT");
contract.Exchange.Should().Be("SMART");
contract.Currency.Should().Be("USD");
contract.Right.Should().Be("C");
contract.Strike.Should().Be(150d);
contract.Multiplier.Should().Be("100");
// Der volle Tag, nicht nur der Monat: sonst liefert TWS bei woechentlichen Verfaellen
// stillschweigend den Standardtermin und damit einen anderen Kontrakt.
contract.LastTradeDateOrContractMonth.Should().Be("20260918");
}
[Fact]
public void Option_Put_MapsToP()
{
var spec = new OptionSpec("AAPL", new DateOnly(2026, 9, 18), 150m, OptionRight.Put);
IbkrMapping.Option(spec).Right.Should().Be("P");
}
[Fact]
public void Option_TradingClass_IsOnlySetWhenKnown()
{
var spec = new OptionSpec("AAPL", new DateOnly(2026, 9, 18), 150m, OptionRight.Put);
IbkrMapping.Option(spec).TradingClass.Should().BeEmpty();
IbkrMapping.Option(spec with { TradingClass = "AAPL1" }).TradingClass.Should().Be("AAPL1");
}
[Fact]
public void ForRequest_PicksStockOrOption_ByTheSignalsInstrument()
{
var stock = new OrderRequest
{
Symbol = "AAPL", Side = TradeSide.Buy, Quantity = 10, Type = OrderType.Market
};
var option = stock with
{
Option = new OptionSpec("AAPL", new DateOnly(2026, 9, 18), 150m, OptionRight.Put)
};
IbkrMapping.ForRequest(stock).SecType.Should().Be("STK");
IbkrMapping.ForRequest(option).SecType.Should().Be("OPT");
}
// ─── 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");
// ─── Herkunft der Zeitangabe (Beobachtbarkeit) ────────────────────────────
//
// Ohne die Herkunft lässt sich später nicht mehr unterscheiden, ob ein Buchungszeitpunkt von
// TWS gemeldet oder von uns angenommen wurde. Genau daran hängt die Diagnose, wenn eine
// Instanz von einem EU- auf einen US-Host umzieht.
[Fact]
public void ResolveExecutionTime_MitZone_MeldetGemeldeteHerkunft()
{
var stamp = IbkrMapping.ResolveExecutionTime("20260804 09:30:00 America/New_York", Berlin);
stamp.Source.Should().Be(IbkrMapping.ExecutionTimeSource.ReportedZone);
stamp.IsAssumed.Should().BeFalse();
stamp.ReportedZone.Should().Be("America/New_York");
stamp.ZoneUsed.Should().Contain("New_York");
stamp.Utc.Should().Be(new DateTime(2026, 8, 4, 13, 30, 0, DateTimeKind.Utc));
}
[Fact]
public void ResolveExecutionTime_OhneZone_MeldetAnnahme()
{
// Der Normalfall bei execDetails - und damit die Stelle, die beobachtet werden muss.
var stamp = IbkrMapping.ResolveExecutionTime("20260804 17:39:18", Berlin);
stamp.Source.Should().Be(IbkrMapping.ExecutionTimeSource.FallbackZone);
stamp.IsAssumed.Should().BeTrue();
stamp.ReportedZone.Should().BeNull();
stamp.ZoneUsed.Should().Be(Berlin.Id);
}
[Fact]
public void ResolveExecutionTime_UnbekannteZone_IstAlsAnnahmeErkennbar()
{
// Weicht auf die Betriebszeitzone aus - aber unterscheidbar von "TWS meldete nichts",
// denn hier hat TWS etwas gemeldet, das dieses System nicht kennt.
var stamp = IbkrMapping.ResolveExecutionTime("20260804 17:39:18 Gibt/EsNicht", Berlin);
stamp.Source.Should().Be(IbkrMapping.ExecutionTimeSource.UnknownZoneFallback);
stamp.IsAssumed.Should().BeTrue();
stamp.ReportedZone.Should().Be("Gibt/EsNicht");
stamp.ZoneUsed.Should().Be(Berlin.Id);
}
[Fact]
public void ResolveExecutionTime_Unlesbar_IstAlsDefektErkennbar()
{
var stamp = IbkrMapping.ResolveExecutionTime("Unsinn", Berlin);
stamp.Source.Should().Be(IbkrMapping.ExecutionTimeSource.Unparsable);
stamp.Utc.Should().BeNull();
// Unlesbar ist keine Annahme, sondern ein Defekt - die Warnung im Log ist eine andere.
stamp.IsAssumed.Should().BeFalse();
}
[Fact]
public void ResolveExecutionTime_UsHost_LiefertDenselbenZeitpunktWieEuHost()
{
// Der eigentliche Regressionsschutz fuer den geplanten Umzug: Meldet TWS die Zone, darf die
// Betriebszeitzone der Instanz das Ergebnis NICHT mehr veraendern.
const string raw = "20260804 09:30:00 America/New_York";
IbkrMapping.ResolveExecutionTime(raw, Berlin).Utc
.Should().Be(IbkrMapping.ResolveExecutionTime(raw, NewYork).Utc);
}
[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();
}
// ─── Ordermeldungen (#489) ─────────────────────────────────────────
///
/// Der Fehler, der 67 Orders unsichtbar gemacht hat: IBKR schickt unter Code 399 Hinweise zu
/// angenommenen Orders – "Your order will not be placed at the exchange until Monday".
/// Als Ablehnung gelesen, gab das Kapitalmodell die Bindung frei, während die Order weiterlief.
///
[Fact]
public void OrderNotice399_DoesNotEndAnOrder()
{
IbkrMapping.IsOrderNotice(399).Should().BeTrue();
IbkrMapping.IsOrderRejection(399).Should().BeFalse();
}
[Theory]
[InlineData(201, true)] // Order rejected
[InlineData(202, true)] // Order cancelled
[InlineData(203, true)] // Symbol nicht erlaubt
[InlineData(399, false)] // Hinweis zu einer angenommenen Order
[InlineData(404, false)] // Order held while securities are located
[InlineData(1100, false)] // Verbindung verloren - sagt nichts ueber die Order
[InlineData(504, false)] // nicht verbunden - dito
public void IsOrderRejection_OnlyForProvenEndings(int code, bool expected) =>
IbkrMapping.IsOrderRejection(code).Should().Be(expected);
[Theory]
[InlineData("Cancelled", true)]
[InlineData("ApiCancelled", true)]
[InlineData("Filled", false)]
[InlineData("PreSubmitted", false)]
[InlineData("Submitted", false)]
[InlineData("Inactive", false)]
public void IsDeadStatus_IsNarrowerThanIsTerminalStatus(string status, bool expected)
{
// "Inactive" ist ein Endstatus, aber kein Beweis: IBKR verwendet ihn auch fuer Orders, die
// spaeter von selbst wieder aktiv werden. Ihn als "tot" zu lesen, gaebe gebundenes Kapital
// frei, waehrend die Order lebt.
IbkrMapping.IsDeadStatus(status).Should().Be(expected);
}
[Fact]
public void EveryDeadStatus_IsAlsoTerminal()
{
// Die eine Richtung muss gelten, sonst wartet PlaceOrderAsync auf einen Endstatus, den es
// anschliessend nicht als Ende akzeptiert.
foreach (var status in new[] { "Cancelled", "ApiCancelled" })
IbkrMapping.IsTerminalStatus(status).Should().BeTrue($"{status} ist ein Endstatus");
}
}