Files
IBKRTrader/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs
T
RichardandClaude Opus 5 b96a20788c R10: Lesender Bestandsabgleich (IBrokerPortfolioReader) + Datenlage-Konzepte
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>
2026-08-06 10:51:51 +02:00

241 lines
9.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}