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(); } // ─── 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); } }