using FluentAssertions;
using IBKRTrader.Core.Trading;
namespace IBKRTrader.Tests.Trading;
///
/// Das Options-Fundament (#298): Instrumentenschlüssel, Kontraktgröße und die Bewertung, die ohne
/// den Multiplikator um Faktor 100 danebenläge.
///
[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();
}
// ─── 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();
}
// ─── 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);
}
}