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