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>
241 lines
9.0 KiB
C#
241 lines
9.0 KiB
C#
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();
|
||
|
||
// ─── 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);
|
||
|
||
[Fact]
|
||
public void ParseExecutionTime_HandlesDoubleSpaceFormat()
|
||
{
|
||
// So liefert TWS es bei execDetails.
|
||
IbkrMapping.ParseExecutionTime("20260804 17:39:18")
|
||
.Should().Be(new DateTime(2026, 8, 4, 17, 39, 18));
|
||
}
|
||
|
||
[Fact]
|
||
public void ParseExecutionTime_IgnoresTrailingTimeZone()
|
||
{
|
||
IbkrMapping.ParseExecutionTime("20260804 17:52:56 Europe/Berlin")
|
||
.Should().Be(new DateTime(2026, 8, 4, 17, 52, 56));
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("")]
|
||
[InlineData(" ")]
|
||
[InlineData("20260804")]
|
||
[InlineData("Unsinn")]
|
||
public void ParseExecutionTime_ReturnsNullForUnusableInput(string raw) =>
|
||
IbkrMapping.ParseExecutionTime(raw).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();
|
||
}
|
||
}
|