Files
IBKRTrader/tests/IBKRTrader.Tests/Trading/InstrumentTests.cs
T
RichardandClaude Opus 5 2a73172e27 Instrumente sind waehrungsfaehig
Die Waehrung gehoert zur Identitaet eines Instruments und nicht zu
seiner Beschreibung: derselbe Ticker kann an mehreren Plaetzen in
verschiedenen Waehrungen notieren, und ein Kauf darf nur aus Bargeld
derselben Waehrung finanziert werden (Kapitalmodell §4 Schicht C). Zwei
Notierungen als eine Position zu fuehren, wuerde Bestand und Deckung
vermischen.

USD bleibt im Instrumentenschluessel IMPLIZIT ("AAPL", nicht
"AAPL:USD"). Andernfalls waere jede Bestandszeile ab sofort ein anderes
Instrument als vorher und aus einer Position wuerden zwei. Trennzeichen
ist der Doppelpunkt und nicht der Punkt - Ticker enthalten Punkte
(BRK.B).

Neu: Currencies (kanonische Schreibweise an allen vier Stellen, die sie
gleich haben muessen), Currency an OptionSpec, InstrumentRef,
TradeSignal, OrderRequest und core_position. CapitalService nimmt die
Waehrung ab jetzt aus dem Instrument statt vom Aufrufer - wer sie
uebergeben koennte, koennte die Deckungspruefung gegen den falschen Topf
laufen lassen. --verify-order kennt --currency.

Anlass: das Paper-Konto haelt EUR 91.832 und USD -3.751. Ein
USD-Nachweis waere ein USD-Kredit gewesen und wurde von Schicht C
zu Recht abgelehnt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:39:30 +02:00

200 lines
7.0 KiB
C#

using FluentAssertions;
using IBKRTrader.Core.Trading;
namespace IBKRTrader.Tests.Trading;
/// <summary>
/// Das Options-Fundament (#298): Instrumentenschlüssel, Kontraktgröße und die Bewertung, die ohne
/// den Multiplikator um Faktor 100 danebenläge.
/// </summary>
[Trait("cat", "unit")]
public class InstrumentTests
{
private static OptionSpec Put150 =>
new("AAPL", new DateOnly(2026, 9, 18), 150m, OptionRight.Put);
private static OptionSpec Call150 =>
new("AAPL", new DateOnly(2026, 9, 18), 150m, OptionRight.Call);
// ─── Schlüssel ───────────────────────────────────────────────────────────────
[Fact]
public void Stock_Key_IsTheTicker()
{
InstrumentRef.ForStock("aapl").Key.Should().Be("AAPL");
InstrumentRef.ForStock(" AAPL ").Key.Should().Be("AAPL");
}
[Fact]
public void Option_Key_CarriesExpiryRightAndStrike()
{
InstrumentRef.ForOption(Put150).Key.Should().Be("AAPL|20260918|P|150");
InstrumentRef.ForOption(Call150).Key.Should().Be("AAPL|20260918|C|150");
}
[Fact]
public void Option_Key_NormalizesTrailingZeroesOfTheStrike()
{
// decimal merkt sich die Nachkommastellen: 150m, 150.0m und 150.000m sind zwar gleich,
// formatieren sich aber unterschiedlich. Ohne Normalisierung waeren das drei Positionen
// auf denselben Kontrakt. (Als double waeren alle drei buchstaeblich derselbe Wert und
// der Test pruefte nichts.)
foreach (var strike in new[] { 150m, 150.0m, 150.000m })
InstrumentRef.ForOption(Put150 with { Strike = strike }).Key
.Should().Be("AAPL|20260918|P|150", "Strike {0}", strike);
}
[Fact]
public void Option_Key_KeepsHalfDollarStrikes()
{
InstrumentRef.ForOption(Put150 with { Strike = 302.5m }).Key
.Should().Be("AAPL|20260918|P|302.5");
}
[Theory]
[InlineData("AAPL")]
[InlineData("AAPL|20260918|P|150")]
[InlineData("AAPL|20260918|C|302.5")]
public void Key_RoundTrips(string key)
{
InstrumentRef.TryParse(key, out var parsed).Should().BeTrue();
parsed.Key.Should().Be(key);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("AAPL|20260918|P")] // zu wenige Teile
[InlineData("AAPL|20260918|P|150|X")] // zu viele Teile
[InlineData("AAPL|18.09.2026|P|150")] // Datum im falschen Format
[InlineData("AAPL|20260918|X|150")] // weder Call noch Put
[InlineData("AAPL|20260918|P|einhundert")] // Strike keine Zahl
public void UnreadableKey_IsRejected_NotGuessed(string key)
{
InstrumentRef.TryParse(key, out _).Should().BeFalse();
}
[Fact]
public void UnderlyingRef_OfAnOption_IsTheStock()
{
InstrumentRef.ForOption(Put150).UnderlyingRef.Should().Be(InstrumentRef.ForStock("AAPL"));
}
[Fact]
public void SymbolContainingTheSeparator_IsRefused()
{
var act = () => InstrumentRef.ForStock("AA|PL");
act.Should().Throw<ArgumentException>();
}
// ─── Währung gehört zur Identität ────────────────────────────────────────────
[Fact]
public void UsdStaysImplicitInTheKey()
{
// Sonst waere jede Bestandszeile ab sofort ein anderes Instrument als vorher, und aus
// einer Position wuerden zwei.
InstrumentRef.ForStock("AAPL").Key.Should().Be("AAPL");
InstrumentRef.ForStock("AAPL", "USD").Key.Should().Be("AAPL");
}
[Fact]
public void AnotherCurrency_BecomesPartOfTheKey()
{
InstrumentRef.ForStock("MIGA", "EUR").Key.Should().Be("MIGA:EUR");
InstrumentRef.ForStock("MIGA", "eur").Currency.Should().Be("EUR");
}
[Fact]
public void TheSameTickerInTwoCurrencies_AreTwoInstruments()
{
// Derselbe Ticker kann an mehreren Plaetzen notieren. Zwei Notierungen als eine Position
// zu fuehren, wuerde Bestand und Deckung vermischen.
InstrumentRef.ForStock("XYZ", "USD").Key
.Should().NotBe(InstrumentRef.ForStock("XYZ", "EUR").Key);
}
[Fact]
public void OptionKey_CarriesTheCurrencyToo()
{
var eur = Put150 with { Currency = "EUR" };
InstrumentRef.ForOption(eur).Key.Should().Be("AAPL:EUR|20260918|P|150");
InstrumentRef.ForOption(Put150).Key.Should().Be("AAPL|20260918|P|150");
}
[Theory]
[InlineData("MIGA:EUR")]
[InlineData("AAPL:EUR|20260918|P|150")]
[InlineData("BRK.B")]
public void KeysWithCurrencyAndDottedTickers_RoundTrip(string key)
{
// Der Punkt ist als Trennzeichen ausgeschieden, weil Ticker ihn enthalten (BRK.B).
InstrumentRef.TryParse(key, out var parsed).Should().BeTrue();
parsed.Key.Should().Be(key);
}
[Fact]
public void UnderlyingOfAForeignOption_KeepsTheCurrency()
{
var eur = InstrumentRef.ForOption(Put150 with { Currency = "EUR" });
eur.UnderlyingRef.Key.Should().Be("MIGA:EUR".Replace("MIGA", "AAPL"));
eur.UnderlyingRef.Currency.Should().Be("EUR");
}
[Theory]
[InlineData("EU")]
[InlineData("EURO")]
[InlineData("eu1")]
public void AnInvalidCurrency_IsRefused(string currency)
{
var act = () => InstrumentRef.ForStock("AAPL", currency);
act.Should().Throw<ArgumentException>();
}
// ─── Bewertung ───────────────────────────────────────────────────────────────
[Fact]
public void Stock_Notional_IsUnchanged()
{
var pos = new Position("CT", InstrumentRef.ForStock("AAPL"), 5, 100m);
pos.Notional.Should().Be(500m);
pos.BoundCapital.Should().Be(500m);
}
[Fact]
public void Option_Notional_UsesTheMultiplier()
{
// Genau der Fehler aus #298: ohne Multiplikator waeren das 2,50 statt 250.
var pos = new Position("OW", InstrumentRef.ForOption(Put150), 1, 2.50m);
pos.Notional.Should().Be(250m);
}
[Fact]
public void ShortPut_BindsTheObligation_NotThePremium()
{
// Praemie 250 USD, Verpflichtung 15.000 USD. Wer hier den Marktwert einsetzt, haelt eine
// Verpflichtung fuer ein Sechzigstel ihrer Groesse.
var pos = new Position("OW", InstrumentRef.ForOption(Put150), -1, 2.50m);
pos.IsShort.Should().BeTrue();
pos.Notional.Should().Be(250m);
pos.BoundCapital.Should().Be(15_000m);
}
[Fact]
public void ShortCall_BindsNoCash_BecauseItIsCoveredByShares()
{
var pos = new Position("OW", InstrumentRef.ForOption(Call150), -1, 2.50m);
pos.BoundCapital.Should().Be(0m);
}
[Fact]
public void CoverageRequirements_AreStrikeTimesMultiplier_AndOneRoundLot()
{
Put150.CashCoveragePerContract.Should().Be(15_000m);
Call150.CoverageShares.Should().Be(100);
}
}