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