using FluentAssertions; using IBKRTrader.Core.Trading; using IBKRTrader.Modules.OptionsWheel.Logic; namespace IBKRTrader.Tests.Modules.OptionsWheel; /// /// Roll-Entscheidung und Prämienrechnung – die beiden Stellen, an denen das Wheel Geld verliert, /// wenn sie falsch sind. /// [Trait("cat", "unit")] public class RollAndPremiumTests { private static readonly DateOnly Today = new(2026, 8, 1); private static OptionSpec Put(decimal strike = 150m, DateOnly? expiry = null) => new("AAPL", expiry ?? new DateOnly(2026, 9, 5), strike, OptionRight.Put); private static WheelRules Rules => new(); // ─── Prämienrechnung ───────────────────────────────────────────────────────── [Fact] public void PremiumPerContract_UsesTheContractSize() { PremiumMath.PremiumPerContract(2.50m, Put()).Should().Be(250m); } [Fact] public void CapitalAtRisk_IsCashForPuts_AndShareValueForCalls() { PremiumMath.CapitalAtRisk(Put(150m), underlyingPrice: 160m).Should().Be(15_000m); var call = new OptionSpec("AAPL", new DateOnly(2026, 9, 5), 150m, OptionRight.Call); PremiumMath.CapitalAtRisk(call, underlyingPrice: 160m).Should().Be(16_000m); } [Fact] public void AnnualizedReturn_PutsShortAndLongContractsOnTheSameScale() { // 250 auf 15.000 in 30 Tagen sind gut 20 % p. a. Ohne den Zeitbezug wuerde die Auswahl // systematisch die lang laufenden Kontrakte bevorzugen - die mit dem langsamsten // Zeitwertverfall. var r = PremiumMath.AnnualizedReturnPercent(250m, 15_000m, 30); r.Should().BeApproximately(20.28m, 0.01m); } [Fact] public void AnnualizedReturn_OfAContractExpiringToday_IsZero_NotADivisionByZero() { PremiumMath.AnnualizedReturnPercent(250m, 15_000m, 0).Should().Be(0m); } [Fact] public void BreakEven_IsStrikeMinusPremiumForAPut() { PremiumMath.BreakEven(Put(150m), premiumPerShare: 2.50m).Should().Be(147.50m); var call = new OptionSpec("AAPL", new DateOnly(2026, 9, 5), 150m, OptionRight.Call); PremiumMath.BreakEven(call, 2.50m).Should().Be(152.50m); } [Fact] public void EffectiveCostBasis_FallsWithEveryPremiumCollected() { // Zugeteilt zu 150, insgesamt 600 Praemie auf 100 Stueck -> Einstand 144. PremiumMath.EffectiveCostBasis(150m, 600m, 100).Should().Be(144m); } [Fact] public void DaysToExpiry_IsNeverNegative() { PremiumMath.DaysToExpiry(new DateOnly(2026, 7, 1), Today).Should().Be(0); } // ─── Rollen ────────────────────────────────────────────────────────────────── private static RollDecider.OpenLeg Leg( DateOnly expiry, decimal delta, decimal underlying, decimal currentPrice = 1.00m, decimal premiumReceived = 250m) => new(Put(150m, expiry), premiumReceived, currentPrice, delta, underlying); [Fact] public void PlentyOfTimeLeft_MeansHold() { var leg = Leg(new DateOnly(2026, 9, 5), delta: 0.20m, underlying: 160m); // 35 Tage RollDecider.Decide(leg, Rules, Today).Action.Should().Be(LegAction.Hold); } [Fact] public void ShortTimeLeftButFarOutOfTheMoney_MeansHold() { // Die Restlaufzeit allein ist kein Grund: ein weit aus dem Geld liegender Kontrakt darf // wertlos verfallen - das ist der Normalfall und der billigste Ausgang. var leg = Leg(new DateOnly(2026, 8, 15), delta: 0.10m, underlying: 180m); // 14 Tage RollDecider.Decide(leg, Rules, Today).Action.Should().Be(LegAction.Hold); } [Fact] public void ShortTimeLeftAndInTheMoney_RollsWhenTheTargetPaysACredit() { var leg = Leg(new DateOnly(2026, 8, 15), delta: 0.60m, underlying: 140m, currentPrice: 11m); var target = Put(145m, new DateOnly(2026, 9, 18)); // Rueckkauf 1.100, neue Praemie 1.400 -> 300 Netto-Kredit. var d = RollDecider.Decide(leg, Rules, Today, target, rollTargetPremium: 1_400m); d.Action.Should().Be(LegAction.Roll); d.Target.Should().Be(target); d.Reason.Should().Contain("Netto-Kredit"); } [Fact] public void ADebitRoll_IsRefused_AndAssignmentIsAllowedInstead() { // Ein Debit-Roll kauft nur Zeit und kostet dabei Geld: er verschiebt das Problem und // verkleinert das Konto. var leg = Leg(new DateOnly(2026, 8, 15), delta: 0.60m, underlying: 140m, currentPrice: 11m); var d = RollDecider.Decide(leg, Rules, Today, Put(145m), rollTargetPremium: 900m); d.Action.Should().Be(LegAction.AllowAssignment); d.Reason.Should().Contain("Belastung"); } [Fact] public void WithoutAnEligibleTarget_AssignmentIsAllowed() { var leg = Leg(new DateOnly(2026, 8, 15), delta: 0.60m, underlying: 140m, currentPrice: 11m); RollDecider.Decide(leg, Rules, Today, rollTarget: null).Action .Should().Be(LegAction.AllowAssignment); } [Fact] public void ProfitTarget_IsOffByDefault() { // 250 vereinnahmt, Rueckkauf kostet nur noch 20 -> 92 % erreicht. Ohne eingeschaltetes // Ziel bleibt das folgenlos. var leg = Leg(new DateOnly(2026, 9, 5), delta: 0.10m, underlying: 170m, currentPrice: 0.20m); RollDecider.Decide(leg, Rules, Today).Action.Should().Be(LegAction.Hold); } [Fact] public void ProfitTarget_ClosesTheLeg_WhenSwitchedOn() { var rules = Rules; rules.ProfitTargetPercent = 50m; var leg = Leg(new DateOnly(2026, 9, 5), delta: 0.10m, underlying: 170m, currentPrice: 0.20m); var d = RollDecider.Decide(leg, rules, Today); d.Action.Should().Be(LegAction.Close); d.Reason.Should().Contain("vereinnahmt"); } [Fact] public void InTheMoney_IsSideAware() { var put = Put(150m); var call = new OptionSpec("AAPL", new DateOnly(2026, 9, 5), 150m, OptionRight.Call); RollDecider.IsInTheMoney(put, 140m).Should().BeTrue(); RollDecider.IsInTheMoney(put, 160m).Should().BeFalse(); RollDecider.IsInTheMoney(call, 160m).Should().BeTrue(); RollDecider.IsInTheMoney(call, 140m).Should().BeFalse(); } }