Files
IBKRTrader/tests/IBKRTrader.Tests/Trading/OptionChainTests.cs
T
RichardandClaude Opus 5 0feeeba815 Optionskette und Bewertungsgroessen im Core
Der Core kennt jetzt die handelbaren Verfallstage und Strikes eines Basiswerts
(reqSecDefOptParams) und die Bewertungsgroessen eines Kontrakts
(tickOptionComputation). Damit steht der letzte Baustein, den eine
Wheel-Strategie braucht, um ueberhaupt einen Kontrakt auswaehlen zu koennen.

OptionChain ist bewusst nur eine Liste und trifft keine Marktentscheidung. Die
Auswahlhilfen daran sind reine Rechnung - "der Verfall bei etwa 35 Tagen" ist
eine Kalenderfrage, "welches Delta will ich" nicht. Letzteres gehoert ins Modul.

Zwei Eigenheiten der TWS-API sind beim Messen aufgefallen und haben je einen
Fehler gekostet:

1. Greeks kommen NICHT im Snapshot. reqMktData mit snapshot=true liefert Geld-,
   Brief- und letzten Kurs, aber keine tickOptionComputation. Es braucht ein Abo,
   das beim ersten Modell-Tick wieder abbestellt wird.

2. TWS meldet je Basiswert MEHRERE Serien. Fuer AAPL kamen "AAPL" und "2AAPL" -
   letztere eine Sonderserie mit eigenen Ausuebungspreisen. Der erstbeste Satz
   war die falsche: Strike 320 in "2AAPL" gibt es nicht, TWS antwortete mit 200
   ("No security definition has been found"). Die Auswahl nimmt jetzt SMART vor
   allen anderen Boersen und darin die Handelsklasse, die dem Symbol entspricht.

Massgeblich ist ausserdem allein das Modell-Feld (13 bzw. 83 bei verzoegerten
Daten). Die Varianten zu Geld-, Brief- und letztem Kurs fehlen bei illiquiden
Serien und lieferten sonst ein Delta aus einem Kurs, den niemand stellt. Fehlende
Werte meldet TWS als double.MaxValue oder -1; sie werden zu null, nicht zu 0 -
ein Delta von 0 hiesse "reagiert nicht auf den Basiswert".

Gemessen gegen das Paper-Gateway:
  AAPL (USD, SMART/AAPL, x100): 22 Verfallstage, 123 Strikes
  Auswahl wie beim Wheel: AAPL|20261009|P|320 (Kurs 320.01, 34 Tage Laufzeit)
  Deckungsbedarf je Kontrakt: 32.000,00 USD
  IV 24.5 %, Delta -0.471, Gamma 0.0169, Vega 0.392, Theta -0.123 (verzoegert)

Der Schalter --option-chain SYMBOL [--currency X] faehrt genau diesen Ablauf und
bleibt als Pruefwerkzeug. 414 Tests gruen.

Behebt #299.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 13:52:27 +02:00

125 lines
5.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using FluentAssertions;
using IBKRTrader.Core.Trading;
namespace IBKRTrader.Tests.Trading;
/// <summary>
/// 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.
/// </summary>
[Trait("cat", "unit")]
public class OptionChainTests
{
private static readonly DateOnly Today = new(2026, 9, 5);
/// <summary>Wochenverfälle plus ein Monatsverfall – wie eine echte Kette.</summary>
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();
}
}