diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs
index ba385e2..76bdbf7 100644
--- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs
+++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs
@@ -15,7 +15,7 @@ namespace IBKRTrader.Core.Trading.Ibkr;
/// fehlgeschlagene Order). Ein Konto mit Wert 0 lässt die Risikoprüfung jedes Signal ablehnen –
/// die sichere Richtung, wenn der Broker nicht erreichbar ist.
///
-public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, IDisposable
+public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, IOptionMarketData, IDisposable
{
private const string LogModule = "IBKR";
@@ -114,5 +114,25 @@ public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, ID
return await _connection.EnsureConnectedAsync(ct).ConfigureAwait(false);
}
+ // ─── Optionsdaten ────────────────────────────────────────────────────────
+
+ ///
+ /// Verfallstage und Strikes eines Basiswerts. Wie überall in diesem Adapter wird ein Fehler
+ /// nicht geworfen, sondern zu einer leeren Antwort: Eine fehlende Kette darf kein Modul
+ /// abstürzen lassen, sie darf nur dazu führen, dass es nichts tut.
+ ///
+ public async Task GetChainAsync(InstrumentRef underlying, CancellationToken ct = default)
+ {
+ if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null;
+ return await _connection!.RequestOptionChainAsync(underlying, _requestTimeout, ct).ConfigureAwait(false);
+ }
+
+ /// Bewertungsgrößen eines Kontrakts; null, wenn der Broker nichts liefert.
+ public async Task GetGreeksAsync(OptionSpec spec, CancellationToken ct = default)
+ {
+ if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null;
+ return await _connection!.RequestGreeksAsync(spec, _requestTimeout, ct).ConfigureAwait(false);
+ }
+
public void Dispose() => _connection?.Dispose();
}
diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs
index 0ce2818..e66b943 100644
--- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs
+++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs
@@ -33,6 +33,8 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
private readonly ConcurrentDictionary _quotes = new();
private readonly ConcurrentDictionary _accounts = new();
private readonly ConcurrentDictionary _contracts = new();
+ private readonly ConcurrentDictionary _chains = new();
+ private readonly ConcurrentDictionary _greeks = new();
private readonly ConcurrentDictionary _orders = new();
private readonly ConcurrentDictionary _executions = new();
private readonly ConcurrentDictionary _contractCache = new(StringComparer.OrdinalIgnoreCase);
@@ -339,6 +341,130 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
}
}
+ ///
+ /// Verfallstage und Strikes eines Basiswerts (reqSecDefOptParams).
+ ///
+ /// Die Abfrage braucht die Kontrakt-ID des Basiswerts, nicht nur sein Symbol – deshalb
+ /// wird der Basiswert zuerst aufgelöst. Ohne die ID liefert TWS die Ketten aller Werte mit
+ /// diesem Symbol an allen Börsen, und was davon zusammengehört, wäre nicht mehr zu erkennen.
+ ///
+ /// TWS antwortet je Börse und Handelsklasse mit einem eigenen Satz. Wir nehmen die Sätze
+ /// von SMART, wenn es sie gibt: das ist die Serie, gegen die auch geordert wird. Die Strikes
+ /// der übrigen Börsen zu mischen würde Ausübungspreise anbieten, die in der gehandelten Serie
+ /// nicht existieren.
+ ///
+ public async Task RequestOptionChainAsync(
+ InstrumentRef underlying, TimeSpan timeout, CancellationToken ct)
+ {
+ var stock = await ResolveContractAsync(underlying, timeout, ct).ConfigureAwait(false);
+ if (stock is null)
+ {
+ _logger.Warn(LogModule, $"Optionskette {underlying.Key}: Basiswert nicht auflösbar.");
+ return null;
+ }
+
+ var id = NextRequestId();
+ var slot = new OptionChainSlot();
+ _chains[id] = slot;
+ try
+ {
+ _socket.reqSecDefOptParams(id, stock.Symbol, "", stock.SecType, stock.ConId);
+
+ if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
+ {
+ _logger.Warn(LogModule, $"Optionskette {underlying.Key}: Zeitüberschreitung.");
+ return null;
+ }
+ if (slot.Error is not null)
+ {
+ _logger.Warn(LogModule, $"Optionskette {underlying.Key}: {slot.Error}");
+ return null;
+ }
+ var series = BestSeries(slot, underlying.Symbol);
+ if (series is null)
+ {
+ _logger.Info(LogModule, $"Optionskette {underlying.Key}: keine Serien gemeldet.");
+ return null;
+ }
+
+ var chain = new OptionChain(
+ underlying.Symbol, underlying.Currency,
+ series.Exchange, series.TradingClass, series.Multiplier,
+ [.. series.Expiries], [.. series.Strikes]);
+
+ _logger.Info(LogModule, $"Optionskette: {chain}");
+ return chain;
+ }
+ finally
+ {
+ // Kein Abbestellen noetig: reqSecDefOptParams ist eine einmalige Antwort, kein Abo.
+ _chains.TryRemove(id, out _);
+ }
+ }
+
+ ///
+ /// Wählt die Serie, gegen die tatsächlich geordert wird.
+ ///
+ /// TWS meldet für einen Basiswert mehrere Sätze - für AAPL etwa die Standardserie
+ /// "AAPL" und Sonderserien wie "2AAPL", die aus Splits oder Sonderdividenden stammen und
+ /// andere Ausübungspreise führen. Nimmt man den erstbesten, entstehen Kontrakte, die es nicht
+ /// gibt: Am 2026-09-05 lieferte die Auswahl der Serie "2AAPL" für Strike 320 die TWS-Meldung
+ /// 200 ("No security definition has been found").
+ ///
+ /// Regel: SMART vor allen anderen Börsen, und innerhalb von SMART die Handelsklasse, die
+ /// dem Symbol entspricht - das ist die Standardserie. Bleibt danach mehr als eine übrig,
+ /// gewinnt die mit den meisten Ausübungspreisen; eine Sonderserie ist stets die dünnere.
+ ///
+ private static OptionSeries? BestSeries(OptionChainSlot slot, string symbol)
+ {
+ lock (slot.Series)
+ {
+ if (slot.Series.Count == 0) return null;
+
+ return slot.Series
+ .OrderByDescending(x => x.Exchange.Equals("SMART", StringComparison.OrdinalIgnoreCase))
+ .ThenByDescending(x => x.TradingClass.Equals(symbol, StringComparison.OrdinalIgnoreCase))
+ .ThenByDescending(x => x.Strikes.Count)
+ .First();
+ }
+ }
+
+ ///
+ /// Bewertungsgrößen eines Kontrakts (tickOptionComputation, Modell-Feld).
+ ///
+ /// Als Momentaufnahme angefordert. Kommt bis zum Ablauf der Frist nichts, ist das kein
+ /// Fehler, sondern die übliche Antwort für eine Serie, die gerade niemand stellt – der
+ /// Aufrufer bekommt null und entscheidet selbst.
+ ///
+ public async Task RequestGreeksAsync(
+ OptionSpec spec, TimeSpan timeout, CancellationToken ct)
+ {
+ var contract = IbkrMapping.Option(spec);
+ var id = NextRequestId();
+ var slot = new GreeksSlot();
+ _greeks[id] = slot;
+ try
+ {
+ // Kein Snapshot, sondern ein Abo: TWS berechnet die Bewertungsgrößen nur im laufenden
+ // Datenstrom und liefert sie in einem Snapshot überhaupt nicht (am 2026-09-05 gegen
+ // das Paper-Gateway gemessen - die Kette kam, die Greeks blieben leer). Das Abo endet
+ // beim ersten Modell-Tick, das finally bestellt es ab.
+ _socket.reqMktData(id, contract, "", false, false, null);
+ await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false);
+
+ if (slot.Error is not null)
+ _logger.Warn(LogModule, $"Bewertung {spec}: {slot.Error}");
+
+ return slot.Value;
+ }
+ finally
+ {
+ _greeks.TryRemove(id, out _);
+ try { _socket.cancelMktData(id); }
+ catch { /* siehe oben */ }
+ }
+ }
+
public async Task RequestAccountAsync(TimeSpan timeout, CancellationToken ct)
{
var id = NextRequestId();
@@ -596,6 +722,65 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
}
}
+ public override void securityDefinitionOptionParameter(
+ int reqId, string exchange, int underlyingConId, string tradingClass, string multiplier,
+ HashSet expirations, HashSet strikes)
+ {
+ if (!_chains.TryGetValue(reqId, out var slot)) return;
+
+ // Jeden Satz aufnehmen. TWS meldet je Boerse UND Handelsklasse einen eigenen; welcher der
+ // richtige ist, entscheidet sich erst, wenn alle da sind.
+ var m = int.TryParse(multiplier, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
+ && parsed > 0 ? parsed : 100;
+
+ var expiries = new SortedSet();
+ foreach (var raw in expirations)
+ if (DateTime.TryParseExact(raw, "yyyyMMdd", CultureInfo.InvariantCulture,
+ DateTimeStyles.None, out var expiry))
+ expiries.Add(DateOnly.FromDateTime(expiry));
+
+ var parsedStrikes = new SortedSet();
+ foreach (var strike in strikes.Where(x => x > 0))
+ parsedStrikes.Add((decimal)strike);
+
+ if (expiries.Count == 0 || parsedStrikes.Count == 0) return;
+
+ lock (slot.Series)
+ slot.Series.Add(new OptionSeries(exchange, tradingClass, m, expiries, parsedStrikes));
+ }
+
+ public override void securityDefinitionOptionParameterEnd(int reqId)
+ {
+ if (_chains.TryGetValue(reqId, out var slot)) slot.Complete();
+ }
+
+ public override void tickOptionComputation(
+ int tickerId, int field, double impliedVolatility, double delta,
+ double optPrice, double pvDividend, double gamma, double vega, double theta, double undPrice)
+ {
+ if (!_greeks.TryGetValue(tickerId, out var slot)) return;
+
+ // Nur das Modell-Feld. Die Varianten zu Geld-, Brief- und letztem Kurs fehlen bei
+ // illiquiden Serien und liefern sonst ein Delta aus einem Kurs, den niemand stellt.
+ if (!IbkrMapping.IsModelOptionTick(field)) return;
+
+ slot.Value = new OptionGreeks(
+ Sane(impliedVolatility), Sane(delta), Sane(gamma), Sane(vega), Sane(theta),
+ Sane(optPrice), Sane(undPrice), IbkrMapping.IsDelayedOptionTick(field));
+
+ slot.Complete();
+ }
+
+ ///
+ /// TWS meldet fehlende Werte als double.MaxValue bzw. -1 und nicht als „kein Wert".
+ /// Unverändert übernommen ergäben sie eine Bewertung, die formal vollständig aussieht.
+ ///
+ private static decimal? Sane(double value) =>
+ double.IsNaN(value) || double.IsInfinity(value) ||
+ Math.Abs(value) >= 1e10 || value == -1d
+ ? null
+ : (decimal)value;
+
public override void accountSummaryEnd(int reqId)
{
if (_accounts.TryGetValue(reqId, out var slot)) slot.Complete();
@@ -805,6 +990,22 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
public Contract? First;
}
+ private sealed class OptionChainSlot : Slot
+ {
+ /// Alle gemeldeten Serien. Die Auswahl faellt erst am Ende - siehe BestSeries.
+ public readonly List Series = new();
+ }
+
+ /// Eine Options-Serie, wie TWS sie meldet: Boerse, Handelsklasse, Kontraktgroesse.
+ private sealed record OptionSeries(
+ string Exchange, string TradingClass, int Multiplier,
+ SortedSet Expiries, SortedSet Strikes);
+
+ private sealed class GreeksSlot : Slot
+ {
+ public OptionGreeks? Value;
+ }
+
private sealed class OrderSlot : Slot
{
public string Status = "";
diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs
index 9589595..f2a2754 100644
--- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs
+++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs
@@ -282,6 +282,22 @@ internal static class IbkrMapping
public static string FormatExecutionFilterTime(DateTime since) =>
since.ToUniversalTime().ToString("yyyyMMdd-HH:mm:ss", CultureInfo.InvariantCulture);
+ ///
+ /// Das Modell-Feld der Bewertungsgrößen: 13 im Echtzeitstrom, 83 im verzögerten.
+ ///
+ /// TWS liefert vier Varianten je Kontrakt - zum Geld-, zum Brief-, zum letzten Kurs und
+ /// aus dem Modell. Maßgeblich ist das Modell: Die anderen drei fehlen bei illiquiden Serien
+ /// oder außerhalb der Handelszeiten schlicht, und ein Delta aus einem Briefkurs, den niemand
+ /// stellt, ist keine Auskunft. Am Paper-Gateway nachgemessen (Roadmap #299).
+ ///
+ public static bool IsModelOptionTick(int field) =>
+ field is TickType.MODEL_OPTION or TickType.DELAYED_MODEL_OPTION;
+
+ /// true, wenn das Feld aus dem verzögerten Datenstrom kommt.
+ public static bool IsDelayedOptionTick(int field) =>
+ field is TickType.DELAYED_MODEL_OPTION or TickType.DELAYED_BID_OPTION
+ or TickType.DELAYED_ASK_OPTION or TickType.DELAYED_LAST_OPTION;
+
public static bool IsLastTick (int field) => field is TickType.LAST or TickType.DELAYED_LAST;
public static bool IsBidTick (int field) => field is TickType.BID or TickType.DELAYED_BID;
public static bool IsAskTick (int field) => field is TickType.ASK or TickType.DELAYED_ASK;
diff --git a/src/IBKRTrader.Core/Trading/NullBrokerClient.cs b/src/IBKRTrader.Core/Trading/NullBrokerClient.cs
index b7d4e5b..ecfe2b1 100644
--- a/src/IBKRTrader.Core/Trading/NullBrokerClient.cs
+++ b/src/IBKRTrader.Core/Trading/NullBrokerClient.cs
@@ -7,7 +7,7 @@ namespace IBKRTrader.Core.Trading;
/// Wird registriert, bis der echte IBKR-Adapter angebunden und gegen den
/// Paper-Gateway verifiziert ist. So kann keine Order versehentlich rausgehen.
///
-public sealed class NullBrokerClient : IBrokerClient, IBrokerPortfolioReader
+public sealed class NullBrokerClient : IBrokerClient, IBrokerPortfolioReader, IOptionMarketData
{
private readonly LoggingService _logger;
@@ -25,6 +25,14 @@ public sealed class NullBrokerClient : IBrokerClient, IBrokerPortfolioReader
public Task GetAccountStateAsync(CancellationToken ct = default)
=> Task.FromResult(new AccountState(0m, 0m));
+ // Optionsdaten: ohne Verbindung gibt es keine Kette und keine Bewertung. Ein Modul, das
+ // hierauf laeuft, findet nichts - und tut damit nichts, was genau die Absicht ist.
+ public Task GetChainAsync(InstrumentRef underlying, CancellationToken ct = default)
+ => Task.FromResult(null);
+
+ public Task GetGreeksAsync(OptionSpec spec, CancellationToken ct = default)
+ => Task.FromResult(null);
+
public Task PlaceOrderAsync(OrderRequest request, CancellationToken ct = default)
{
_logger.Warn("Core",
diff --git a/src/IBKRTrader.Core/Trading/OptionChain.cs b/src/IBKRTrader.Core/Trading/OptionChain.cs
new file mode 100644
index 0000000..3c4e043
--- /dev/null
+++ b/src/IBKRTrader.Core/Trading/OptionChain.cs
@@ -0,0 +1,139 @@
+using System.Globalization;
+
+namespace IBKRTrader.Core.Trading;
+
+///
+/// Die handelbaren Verfallstage und Ausübungspreise eines Basiswerts, so wie der Broker sie führt.
+///
+/// Die Kette ist bewusst nur eine Liste und trifft keine Auswahl. Welcher Verfall und
+/// welcher Strike zu einer Strategie passen, entscheidet das Modul – der Core sagt lediglich, was
+/// es überhaupt gibt. Die Auswahlhilfen unten sind reine Rechnung ohne Marktmeinung: „der nächste
+/// Verfall ab N Tagen" ist eine Kalenderfrage, „welches Delta will ich" nicht.
+///
+/// Warum Börse und Handelsklasse dazugehören: Derselbe Basiswert hat oft mehrere
+/// Optionsklassen (etwa wöchentliche und monatliche Serien, oder Mini-Kontrakte mit
+/// abweichendem Multiplikator). Wer nur Symbol und Strike weitergibt, kann in einer anderen Serie
+/// landen als geprüft – mit anderer Kontraktgröße und damit anderer Deckung.
+///
+public sealed record OptionChain(
+ string Underlying,
+ string Currency,
+ string Exchange,
+ string TradingClass,
+ int Multiplier,
+ IReadOnlyList Expiries,
+ IReadOnlyList Strikes)
+{
+ public bool IsEmpty => Expiries.Count == 0 || Strikes.Count == 0;
+
+ ///
+ /// Der früheste Verfall, der mindestens Tage entfernt ist –
+ /// null, wenn keiner so weit draußen liegt.
+ ///
+ /// Die Untergrenze ist der Punkt: Ein Kontrakt, der morgen verfällt, ist für eine
+ /// Prämienstrategie wertlos und für eine Zuteilung gefährlich.
+ ///
+ public DateOnly? NextExpiryFrom(DateOnly today, int minDaysOut)
+ {
+ var earliest = today.AddDays(minDaysOut);
+ return Expiries.Where(e => e >= earliest).OrderBy(e => e).Select(e => (DateOnly?)e).FirstOrDefault();
+ }
+
+ ///
+ /// Der Verfall, der einer gewünschten Restlaufzeit am nächsten kommt – die übliche Wahl für
+ /// eine Wheel-Strategie („etwa 35 Tage"). Bei Gleichstand gewinnt der spätere: mehr Zeitwert.
+ ///
+ public DateOnly? ExpiryClosestTo(DateOnly today, int targetDaysOut)
+ {
+ if (Expiries.Count == 0) return null;
+
+ var target = today.AddDays(targetDaysOut);
+ return Expiries
+ .OrderBy(e => Math.Abs(e.DayNumber - target.DayNumber))
+ .ThenByDescending(e => e)
+ .First();
+ }
+
+ /// Der handelbare Strike, der am nächsten liegt.
+ public decimal? StrikeNearest(decimal price) =>
+ Strikes.Count == 0 ? null : Strikes.OrderBy(s => Math.Abs(s - price)).ThenBy(s => s).First();
+
+ ///
+ /// Der höchste Strike unter – für einen aus dem Geld
+ /// verkauften Put. null, wenn alle Strikes darüber liegen.
+ ///
+ public decimal? HighestStrikeBelow(decimal price)
+ {
+ var candidates = Strikes.Where(s => s < price).ToList();
+ return candidates.Count == 0 ? null : candidates.Max();
+ }
+
+ ///
+ /// Der niedrigste Strike über – für einen gedeckt verkauften
+ /// Call. null, wenn alle Strikes darunter liegen.
+ ///
+ public decimal? LowestStrikeAbove(decimal price)
+ {
+ var candidates = Strikes.Where(s => s > price).ToList();
+ return candidates.Count == 0 ? null : candidates.Min();
+ }
+
+ ///
+ /// Baut den Kontrakt zu einer Auswahl aus dieser Kette. Übernimmt Währung, Multiplikator und
+ /// Handelsklasse aus der Kette – genau deshalb gibt es diesen Weg: Wer die Felder von Hand
+ /// zusammensetzt, kann in einer anderen Serie landen als der, die er geprüft hat.
+ ///
+ public OptionSpec Spec(DateOnly expiry, decimal strike, OptionRight right) =>
+ new(Underlying, expiry, strike, right, Multiplier, TradingClass, Currency);
+
+ public override string ToString() => FormattableString.Invariant(
+ $"{Underlying} ({Currency}, {Exchange}/{TradingClass}, x{Multiplier}): {Expiries.Count} Verfallstage, {Strikes.Count} Strikes");
+}
+
+///
+/// Bewertungsgrößen eines Optionskontrakts, wie der Broker sie rechnet.
+///
+/// Alle Werte sind nullbar, und das ist keine Nachlässigkeit: Bei verzögerten Daten,
+/// außerhalb der Handelszeiten und bei illiquiden Serien liefert TWS einzelne Felder schlicht
+/// nicht. Ein fehlendes Delta als 0 zu lesen wäre die gefährlichste aller Vereinfachungen – 0
+/// bedeutet „reagiert nicht auf den Basiswert".
+///
+///
+/// true, wenn die Werte aus dem verzögerten Datenstrom stammen (Tick-Feld 83 statt 13). Für die
+/// Strike-Auswahl brauchbar, für eine Entscheidung im Sekundenbereich nicht.
+///
+public sealed record OptionGreeks(
+ decimal? ImpliedVolatility,
+ decimal? Delta,
+ decimal? Gamma,
+ decimal? Vega,
+ decimal? Theta,
+ decimal? OptionPrice,
+ decimal? UnderlyingPrice,
+ bool Delayed)
+{
+ /// Ohne Delta und implizite Volatilität ist die Antwort für eine Auswahl unbrauchbar.
+ public bool IsUsable => Delta is not null && ImpliedVolatility is not null;
+
+ public override string ToString() => FormattableString.Invariant(
+ $"IV {ImpliedVolatility:P1}, Delta {Delta:F3}, Gamma {Gamma:F4}, Vega {Vega:F3}, Theta {Theta:F3}, Preis {OptionPrice:F2}, Basiswert {UnderlyingPrice:F2}{(Delayed ? " (verzögert)" : "")}");
+}
+
+///
+/// Zugang zu Optionsketten und Bewertungsgrößen. Getrennt von , weil
+/// es hier weder um Handel noch um Bestand geht: Ein Modul, das nur Ketten liest, soll nicht
+/// nebenbei Orders auslösen können.
+///
+public interface IOptionMarketData
+{
+ ///
+ /// Verfallstage und Strikes zu einem Basiswert. null, wenn der Broker nichts liefert –
+ /// etwa weil auf den Wert keine Optionen gehandelt werden.
+ ///
+ Task GetChainAsync(InstrumentRef underlying, CancellationToken ct = default);
+
+ ///
+ /// Bewertungsgrößen eines konkreten Kontrakts. null, wenn der Broker nichts liefert.
+ ///
+ Task GetGreeksAsync(OptionSpec spec, CancellationToken ct = default);
+}
diff --git a/src/IBKRTrader.Daemon/Program.cs b/src/IBKRTrader.Daemon/Program.cs
index 70d78c1..bcd76a6 100644
--- a/src/IBKRTrader.Daemon/Program.cs
+++ b/src/IBKRTrader.Daemon/Program.cs
@@ -3,6 +3,7 @@ using IBKRTrader.Core.Configuration;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Reconciliation;
using IBKRTrader.Core.Settings;
+using IBKRTrader.Core.Time;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Hosting;
@@ -52,6 +53,9 @@ internal static class Program
// Bestandsabgleich von Hand. Mit --dry-run wird nur festgestellt, nicht gebucht.
var reconcile = HasFlag(args, "--reconcile");
+ // Optionskette und Bewertungsgrößen eines Basiswerts ansehen. Liest nur.
+ var chainSymbol = ArgAfter(args, "--option-chain");
+
var modules = AppHostBuilder.CreateModules();
using var host = AppHostBuilder.Build(modules);
@@ -61,13 +65,13 @@ internal static class Program
// sie nur in der Logdatei, sah ein CI-Schritt bloß "OK" und Exitcode 0 – auch dann, wenn
// dort "kein Master-Key" und "DB ohne SslMode" untereinander lagen.
var warnings = 0;
- if (checkOnly || ibkrCheck || verifyOrder is not null || reconcile)
+ if (checkOnly || ibkrCheck || verifyOrder is not null || reconcile || chainSymbol is not null)
{
logger.EntryWritten += e =>
{
// Bei der IBKR-Prüfung zählt auch, was der Adapter an Info meldet: Zeitkontext,
// ServerVersion und Kontoliste stehen dort und sind der halbe Befund.
- var floor = ibkrCheck || verifyOrder is not null || reconcile
+ var floor = ibkrCheck || verifyOrder is not null || reconcile || chainSymbol is not null
? AppLogLevel.Info : AppLogLevel.Warn;
if (e.Level < floor) return;
if (e.Level >= AppLogLevel.Warn) Interlocked.Increment(ref warnings);
@@ -83,6 +87,10 @@ internal static class Program
if (reconcile)
return await RunReconcileAsync(host.Services, !HasFlag(args, "--dry-run")).ConfigureAwait(false);
+ if (chainSymbol is not null)
+ return await RunOptionChainAsync(host.Services, chainSymbol,
+ ArgAfter(args, "--currency") ?? Currencies.Default).ConfigureAwait(false);
+
if (ibkrCheck)
return await RunIbkrCheckAsync(host.Services, SymbolArg(args) ?? "AAPL").ConfigureAwait(false);
@@ -119,6 +127,64 @@ internal static class Program
/// die den Schalter besteht, kann sonst vollständig ohne Datenbank dastehen – und genau dafür
/// ist er gedacht.
///
+ ///
+ /// Zeigt die Optionskette eines Basiswerts und die Bewertungsgrößen des Kontrakts, den eine
+ /// Wheel-Strategie als Erstes ansehen würde: der Verfall bei etwa 35 Tagen, der höchste Strike
+ /// unter dem Kurs, ein Put. Liest nur – es wird nichts platziert.
+ ///
+ private static async Task RunOptionChainAsync(
+ IServiceProvider services, string symbol, string currency)
+ {
+ var market = services.GetRequiredService();
+ var broker = services.GetRequiredService();
+ var underlying = InstrumentRef.ForStock(symbol, currency);
+ var inv = System.Globalization.CultureInfo.InvariantCulture;
+
+ Console.WriteLine($"Optionskette für {underlying.Key}");
+
+ var chain = await market.GetChainAsync(underlying).ConfigureAwait(false);
+ if (chain is null || chain.IsEmpty)
+ {
+ Console.Error.WriteLine("Keine Kette erhalten. Werden auf diesen Wert Optionen gehandelt, " +
+ "und stimmt die Währung?");
+ return 1;
+ }
+
+ Console.WriteLine($" {chain}");
+ Console.WriteLine($" Verfallstage (erste 6): {string.Join(", ", chain.Expiries.Take(6))}");
+ Console.WriteLine($" Strikes: {chain.Strikes[0].ToString(inv)} … {chain.Strikes[^1].ToString(inv)}");
+
+ var quote = await broker.GetQuoteAsync(underlying).ConfigureAwait(false);
+ if (quote is null)
+ {
+ Console.WriteLine(" Kein Kurs verfügbar – ohne ihn keine Strike-Auswahl.");
+ return 0;
+ }
+
+ var today = DateOnly.FromDateTime(AppTimeZone.Now);
+ var expiry = chain.ExpiryClosestTo(today, targetDaysOut: 35);
+ var strike = chain.HighestStrikeBelow(quote.Last);
+
+ if (expiry is null || strike is null)
+ {
+ Console.WriteLine($" Kurs {quote.Last.ToString(inv)}, aber keine passende Auswahl " +
+ "(kein Verfall in der Nähe oder kein Strike darunter).");
+ return 0;
+ }
+
+ var spec = chain.Spec(expiry.Value, strike.Value, OptionRight.Put);
+ Console.WriteLine($" Auswahl wie beim Wheel: {spec} " +
+ $"(Kurs {quote.Last.ToString(inv)}, {(expiry.Value.DayNumber - today.DayNumber)} Tage Laufzeit)");
+ Console.WriteLine($" Deckungsbedarf je Kontrakt: {spec.CashCoveragePerContract.ToString("N2", inv)} {spec.Currency}");
+
+ var greeks = await market.GetGreeksAsync(spec).ConfigureAwait(false);
+ Console.WriteLine(greeks is null
+ ? " Keine Bewertungsgrößen geliefert (bei geschlossener Börse und illiquiden Serien normal)."
+ : $" {greeks}");
+
+ return 0;
+ }
+
///
/// Bestandsabgleich von Hand. Ohne --dry-run werden die Korrekturen gebucht;
/// gehandelt wird in keinem Fall.
diff --git a/src/IBKRTrader.Hosting/AppHostBuilder.cs b/src/IBKRTrader.Hosting/AppHostBuilder.cs
index cdc8c0d..e0d81d1 100644
--- a/src/IBKRTrader.Hosting/AppHostBuilder.cs
+++ b/src/IBKRTrader.Hosting/AppHostBuilder.cs
@@ -264,12 +264,14 @@ public static class AppHostBuilder
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => sp.GetRequiredService());
}
else
{
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => sp.GetRequiredService());
}
// Core-Worker/Services
diff --git a/tests/IBKRTrader.Tests/Trading/OptionChainTests.cs b/tests/IBKRTrader.Tests/Trading/OptionChainTests.cs
new file mode 100644
index 0000000..e410057
--- /dev/null
+++ b/tests/IBKRTrader.Tests/Trading/OptionChainTests.cs
@@ -0,0 +1,124 @@
+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();
+ }
+}