using FluentAssertions; using IBKRTrader.Core.Trading; using IBKRTrader.Modules.OptionsWheel.Logic; namespace IBKRTrader.Tests.Modules.OptionsWheel; /// /// Die Strike-Wahl (#310). Delta-basiert im Band 0,15–0,30, nur Watchlist, kein Screening – und /// jede Verwerfung mit Begründung, weil bei Vollautomatik die nicht getroffene Wahl die wichtigste /// Information für die spätere Fehlersuche ist. /// [Trait("cat", "unit")] public class StrikeSelectorTests { private readonly StrikeSelector _sut = new(); private static readonly DateOnly Today = new(2026, 8, 1); /// 35 Tage Restlaufzeit – mitten im Fenster 30–45. private static readonly DateOnly Good = new(2026, 9, 5); private static WheelRules Rules => new(); private static OptionQuote Put( decimal strike, decimal delta, decimal bid = 3.00m, decimal ask = 3.20m, DateOnly? expiry = null, int openInterest = 5000) => new(new OptionSpec("AAPL", expiry ?? Good, strike, OptionRight.Put), bid, ask, Last: 0m, Delta: -delta, ImpliedVolatility: 0.27m, OpenInterest: openInterest); private static OptionQuote Call( decimal strike, decimal delta, decimal bid = 3.00m, decimal ask = 3.20m) => new(new OptionSpec("AAPL", Good, strike, OptionRight.Call), bid, ask, Last: 0m, Delta: delta, ImpliedVolatility: 0.27m, OpenInterest: 5000); // ─── Auswahl ───────────────────────────────────────────────────────────────── [Fact] public void PicksTheStrikeClosestToTheTargetDelta() { // Ziel 0,20. Nicht die hoechste Praemie gewinnt - die haette immer der Kontrakt mit dem // groessten Zuteilungsrisiko. var chain = new[] { Put(145m, 0.28m), Put(140m, 0.19m), Put(135m, 0.16m) }; var result = _sut.Select(chain, Rules, Today, underlyingPrice: 150m); result.Chosen.Should().NotBeNull(); result.Chosen!.Quote.Spec.Strike.Should().Be(140m); } [Fact] public void EveryCandidate_IsKept_WithItsReason() { var chain = new[] { Put(140m, 0.19m), Put(120m, 0.05m) }; var result = _sut.Select(chain, Rules, Today, 150m); result.Evaluated.Should().HaveCount(2); result.Evaluated.Should().ContainSingle(c => c.IsEligible); result.Evaluated.Single(c => !c.IsEligible).Rejection .Should().Be(RejectionReason.DeltaOutOfBand); } [Fact] public void NothingEligible_MeansNoChoice_NotABadChoice() { var result = _sut.Select([Put(120m, 0.05m)], Rules, Today, 150m); result.Chosen.Should().BeNull(); result.Evaluated.Should().HaveCount(1); } // ─── Verwerfungen ──────────────────────────────────────────────────────────── [Fact] public void ExpiryOutsideTheWindow_IsRejected() { var tooSoon = Put(140m, 0.20m, expiry: new DateOnly(2026, 8, 10)); // 9 Tage var tooLate = Put(140m, 0.20m, expiry: new DateOnly(2026, 12, 18)); // 139 Tage _sut.Select([tooSoon], Rules, Today, 150m).Evaluated[0].Rejection .Should().Be(RejectionReason.DaysToExpiry); _sut.Select([tooLate], Rules, Today, 150m).Evaluated[0].Rejection .Should().Be(RejectionReason.DaysToExpiry); } [Fact] public void WithoutADelta_TheChoiceIsNotJustifiable_AndTheCandidateIsRejected() { var noDelta = new OptionQuote( new OptionSpec("AAPL", Good, 140m, OptionRight.Put), 3.00m, 3.20m, Delta: null); _sut.Select([noDelta], Rules, Today, 150m).Evaluated[0].Rejection .Should().Be(RejectionReason.NoDelta); } [Fact] public void PutDeltaIsJudgedByItsMagnitude() { // TWS meldet fuer Puts ein negatives Delta. Das Zielband ist fuer beide Seiten als Betrag // gemeint - ohne den Betrag faende die Auswahl bei Puts nie einen Treffer. var result = _sut.Select([Put(140m, 0.20m)], Rules, Today, 150m); result.Chosen.Should().NotBeNull(); result.Chosen!.Quote.Delta.Should().Be(-0.20m); } [Fact] public void AWideSpread_IsRejected() { // Ein weiter Markt kostet beim Oeffnen, beim Schliessen erneut und beim Rollen ein drittes Mal. var wide = Put(140m, 0.20m, bid: 2.00m, ask: 4.00m); // Spanne 66 % _sut.Select([wide], Rules, Today, 150m).Evaluated[0].Rejection .Should().Be(RejectionReason.SpreadTooWide); } [Fact] public void AOneSidedMarket_IsRejected_NotTreatedAsZeroSpread() { var oneSided = Put(140m, 0.20m, bid: 0m, ask: 3.20m); _sut.Select([oneSided], Rules, Today, 150m).Evaluated[0].Rejection .Should().Be(RejectionReason.SpreadTooWide); } [Fact] public void AnIlliquidContract_IsRejected_BecauseItCannotBeLeft() { var thin = Put(140m, 0.20m, openInterest: 3); _sut.Select([thin], Rules, Today, 150m).Evaluated[0].Rejection .Should().Be(RejectionReason.Illiquid); } [Fact] public void APremiumBelowTheFloor_IsRejected() { var rules = Rules; rules.MinPremiumPerContract = 500m; // 3,10 x 100 = 310 liegt darunter _sut.Select([Put(140m, 0.20m)], rules, Today, 150m).Evaluated[0].Rejection .Should().Be(RejectionReason.PremiumTooLow); } [Fact] public void AnAnnualizedReturnBelowTheFloor_IsRejected() { var rules = Rules; rules.MinAnnualizedReturnPercent = 500m; _sut.Select([Put(140m, 0.20m)], rules, Today, 150m).Evaluated[0].Rejection .Should().Be(RejectionReason.ReturnTooLow); } // ─── Die Regel, die den Wheel vor sich selbst schützt ──────────────────────── [Fact] public void ACallBelowTheEffectiveCostBasis_IsRejected() { // Sonst realisiert das Wheel bei jedem Abruf einen Verlust und verdient dennoch Praemie – // ein Muster, das in der Auswertung lange gut aussieht. var result = _sut.Select([Call(130m, 0.20m)], Rules, Today, 150m, minCallStrike: 145m); result.Chosen.Should().BeNull(); result.Evaluated[0].Rejection.Should().Be(RejectionReason.BelowCostBasis); } [Fact] public void ACallAtOrAboveTheCostBasis_IsAllowed() { _sut.Select([Call(150m, 0.20m)], Rules, Today, 150m, minCallStrike: 145m) .Chosen.Should().NotBeNull(); } [Fact] public void ThePutPath_IgnoresTheCostBasisFloor() { // Der Einstand betrifft nur den Covered Call: beim Put gibt es noch keine Aktien. _sut.Select([Put(140m, 0.20m)], Rules, Today, 150m, minCallStrike: 145m) .Chosen.Should().NotBeNull(); } }