using FluentAssertions; using IBKRTrader.Core.Trading; namespace IBKRTrader.Tests.Trading; /// /// Die Auswahlhilfen der Optionskette (#299). Reine Rechnung – sie entscheidet nichts über den /// Markt, sondern beantwortet Kalender- und Sortierfragen. Genau deshalb gehört sie in den Core /// und ist ohne Broker prüfbar. /// [Trait("cat", "unit")] public class OptionChainTests { private static readonly DateOnly Today = new(2026, 9, 5); /// Wochenverfälle plus ein Monatsverfall – wie eine echte Kette. private static OptionChain Chain(params int[] daysOut) => new( "AAPL", "USD", "SMART", "AAPL", 100, [.. daysOut.Select(d => Today.AddDays(d))], [300m, 310m, 320m, 325m, 330m, 340m]); // ─── Verfall ───────────────────────────────────────────────────────────────── [Fact] public void NextExpiryFrom_SkipsAnythingTooClose() { var chain = Chain(1, 8, 15, 36); // Ein Kontrakt, der morgen verfällt, trägt keine Prämie und ist bei Zuteilung gefährlich. chain.NextExpiryFrom(Today, minDaysOut: 7).Should().Be(Today.AddDays(8)); chain.NextExpiryFrom(Today, minDaysOut: 20).Should().Be(Today.AddDays(36)); } [Fact] public void NextExpiryFrom_AnswersNullWhenNothingIsFarEnoughOut() { Chain(1, 3).NextExpiryFrom(Today, minDaysOut: 30).Should().BeNull(); } [Fact] public void ExpiryClosestTo_PicksTheNearestTarget() { var chain = Chain(8, 29, 43, 71); // Die übliche Wheel-Wahl: „etwa 35 Tage". chain.ExpiryClosestTo(Today, targetDaysOut: 35).Should().Be(Today.AddDays(29), "29 liegt 6 Tage daneben, 43 liegt 8 daneben"); } [Fact] public void ExpiryClosestTo_PrefersTheLaterOneOnATie() { var chain = Chain(30, 40); // Gleich weit entfernt: der spätere trägt mehr Zeitwert. chain.ExpiryClosestTo(Today, targetDaysOut: 35).Should().Be(Today.AddDays(40)); } [Fact] public void ExpiryClosestTo_OnAnEmptyChain_IsNull() { var empty = new OptionChain("AAPL", "USD", "SMART", "AAPL", 100, [], []); empty.IsEmpty.Should().BeTrue(); empty.ExpiryClosestTo(Today, 35).Should().BeNull(); empty.StrikeNearest(320m).Should().BeNull(); } // ─── Strike ────────────────────────────────────────────────────────────────── [Fact] public void StrikeNearest_PicksTheClosestTradableStrike() { Chain(30).StrikeNearest(327m).Should().Be(325m); Chain(30).StrikeNearest(322.5m).Should().Be(320m, "bei Gleichstand gewinnt der niedrigere"); } [Fact] public void HighestStrikeBelow_IsTheOutOfTheMoneyPut() { // Cash-Secured Put: aus dem Geld, also unter dem Kurs. Chain(30).HighestStrikeBelow(327m).Should().Be(325m); Chain(30).HighestStrikeBelow(300m).Should().BeNull("es gibt keinen Strike darunter"); } [Fact] public void LowestStrikeAbove_IsTheCoveredCall() { // Covered Call: aus dem Geld, also über dem Kurs. Chain(30).LowestStrikeAbove(327m).Should().Be(330m); Chain(30).LowestStrikeAbove(340m).Should().BeNull(); } // ─── Kontrakt aus der Kette ───────────────────────────────────────────────── [Fact] public void Spec_CarriesCurrencyMultiplierAndTradingClassFromTheChain() { // Eine Kette mit abweichendem Multiplikator – etwa eine Mini-Serie. Wer die Felder von // Hand zusammensetzt, landet leicht bei 100 und damit bei einer falschen Deckung. var chain = new OptionChain("SAP", "EUR", "SMART", "SAP1", 10, [Today.AddDays(30)], [180m, 190m]); var spec = chain.Spec(Today.AddDays(30), 180m, OptionRight.Put); spec.Underlying.Should().Be("SAP"); spec.Currency.Should().Be("EUR"); spec.Multiplier.Should().Be(10); spec.TradingClass.Should().Be("SAP1"); spec.CashCoveragePerContract.Should().Be(1_800m, "Strike × Multiplikator, nicht × 100"); } // ─── Bewertungsgrößen ─────────────────────────────────────────────────────── [Fact] public void Greeks_WithoutDeltaOrVolatility_AreNotUsable() { var incomplete = new OptionGreeks(null, null, null, null, null, 2.5m, 327m, Delayed: true); incomplete.IsUsable.Should().BeFalse("ohne Delta ist keine Strike-Auswahl möglich"); var usable = new OptionGreeks(0.28m, -0.31m, 0.02m, 0.15m, -0.04m, 2.5m, 327m, Delayed: true); usable.IsUsable.Should().BeTrue(); } }