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