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>
This commit is contained in:
@@ -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.
|
||||
/// </summary>
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<OptionChain?> GetChainAsync(InstrumentRef underlying, CancellationToken ct = default)
|
||||
{
|
||||
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null;
|
||||
return await _connection!.RequestOptionChainAsync(underlying, _requestTimeout, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Bewertungsgrößen eines Kontrakts; <c>null</c>, wenn der Broker nichts liefert.</summary>
|
||||
public async Task<OptionGreeks?> 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();
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
||||
private readonly ConcurrentDictionary<int, QuoteSlot> _quotes = new();
|
||||
private readonly ConcurrentDictionary<int, AccountSlot> _accounts = new();
|
||||
private readonly ConcurrentDictionary<int, ContractSlot> _contracts = new();
|
||||
private readonly ConcurrentDictionary<int, OptionChainSlot> _chains = new();
|
||||
private readonly ConcurrentDictionary<int, GreeksSlot> _greeks = new();
|
||||
private readonly ConcurrentDictionary<int, OrderSlot> _orders = new();
|
||||
private readonly ConcurrentDictionary<int, ExecutionSlot> _executions = new();
|
||||
private readonly ConcurrentDictionary<string, Contract> _contractCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -339,6 +341,130 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verfallstage und Strikes eines Basiswerts (<c>reqSecDefOptParams</c>).
|
||||
///
|
||||
/// <para>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.</para>
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public async Task<OptionChain?> 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 _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wählt die Serie, gegen die tatsächlich geordert wird.
|
||||
///
|
||||
/// <para>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").</para>
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bewertungsgrößen eines Kontrakts (<c>tickOptionComputation</c>, Modell-Feld).
|
||||
///
|
||||
/// <para>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 <c>null</c> und entscheidet selbst.</para>
|
||||
/// </summary>
|
||||
public async Task<OptionGreeks?> 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<AccountState?> 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<string> expirations, HashSet<double> 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<DateOnly>();
|
||||
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<decimal>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TWS meldet fehlende Werte als <c>double.MaxValue</c> bzw. -1 und nicht als „kein Wert".
|
||||
/// Unverändert übernommen ergäben sie eine Bewertung, die formal vollständig aussieht.
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
/// <summary>Alle gemeldeten Serien. Die Auswahl faellt erst am Ende - siehe BestSeries.</summary>
|
||||
public readonly List<OptionSeries> Series = new();
|
||||
}
|
||||
|
||||
/// <summary>Eine Options-Serie, wie TWS sie meldet: Boerse, Handelsklasse, Kontraktgroesse.</summary>
|
||||
private sealed record OptionSeries(
|
||||
string Exchange, string TradingClass, int Multiplier,
|
||||
SortedSet<DateOnly> Expiries, SortedSet<decimal> Strikes);
|
||||
|
||||
private sealed class GreeksSlot : Slot
|
||||
{
|
||||
public OptionGreeks? Value;
|
||||
}
|
||||
|
||||
private sealed class OrderSlot : Slot
|
||||
{
|
||||
public string Status = "";
|
||||
|
||||
@@ -282,6 +282,22 @@ internal static class IbkrMapping
|
||||
public static string FormatExecutionFilterTime(DateTime since) =>
|
||||
since.ToUniversalTime().ToString("yyyyMMdd-HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Das Modell-Feld der Bewertungsgrößen: 13 im Echtzeitstrom, 83 im verzögerten.
|
||||
///
|
||||
/// <para>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).</para>
|
||||
/// </summary>
|
||||
public static bool IsModelOptionTick(int field) =>
|
||||
field is TickType.MODEL_OPTION or TickType.DELAYED_MODEL_OPTION;
|
||||
|
||||
/// <summary>true, wenn das Feld aus dem verzögerten Datenstrom kommt.</summary>
|
||||
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;
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
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<AccountState> 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<OptionChain?> GetChainAsync(InstrumentRef underlying, CancellationToken ct = default)
|
||||
=> Task.FromResult<OptionChain?>(null);
|
||||
|
||||
public Task<OptionGreeks?> GetGreeksAsync(OptionSpec spec, CancellationToken ct = default)
|
||||
=> Task.FromResult<OptionGreeks?>(null);
|
||||
|
||||
public Task<OrderResult> PlaceOrderAsync(OrderRequest request, CancellationToken ct = default)
|
||||
{
|
||||
_logger.Warn("Core",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace IBKRTrader.Core.Trading;
|
||||
|
||||
/// <summary>
|
||||
/// Die handelbaren Verfallstage und Ausübungspreise eines Basiswerts, so wie der Broker sie führt.
|
||||
///
|
||||
/// <para>Die Kette ist bewusst <b>nur eine Liste</b> 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.</para>
|
||||
///
|
||||
/// <para><b>Warum Börse und Handelsklasse dazugehören:</b> 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.</para>
|
||||
/// </summary>
|
||||
public sealed record OptionChain(
|
||||
string Underlying,
|
||||
string Currency,
|
||||
string Exchange,
|
||||
string TradingClass,
|
||||
int Multiplier,
|
||||
IReadOnlyList<DateOnly> Expiries,
|
||||
IReadOnlyList<decimal> Strikes)
|
||||
{
|
||||
public bool IsEmpty => Expiries.Count == 0 || Strikes.Count == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Der früheste Verfall, der mindestens <paramref name="minDaysOut"/> Tage entfernt ist –
|
||||
/// <c>null</c>, wenn keiner so weit draußen liegt.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Der handelbare Strike, der <paramref name="price"/> am nächsten liegt.</summary>
|
||||
public decimal? StrikeNearest(decimal price) =>
|
||||
Strikes.Count == 0 ? null : Strikes.OrderBy(s => Math.Abs(s - price)).ThenBy(s => s).First();
|
||||
|
||||
/// <summary>
|
||||
/// Der höchste Strike <b>unter</b> <paramref name="price"/> – für einen aus dem Geld
|
||||
/// verkauften Put. <c>null</c>, wenn alle Strikes darüber liegen.
|
||||
/// </summary>
|
||||
public decimal? HighestStrikeBelow(decimal price)
|
||||
{
|
||||
var candidates = Strikes.Where(s => s < price).ToList();
|
||||
return candidates.Count == 0 ? null : candidates.Max();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der niedrigste Strike <b>über</b> <paramref name="price"/> – für einen gedeckt verkauften
|
||||
/// Call. <c>null</c>, wenn alle Strikes darunter liegen.
|
||||
/// </summary>
|
||||
public decimal? LowestStrikeAbove(decimal price)
|
||||
{
|
||||
var candidates = Strikes.Where(s => s > price).ToList();
|
||||
return candidates.Count == 0 ? null : candidates.Min();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bewertungsgrößen eines Optionskontrakts, wie der Broker sie rechnet.
|
||||
///
|
||||
/// <para>Alle Werte sind <b>nullbar</b>, 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".</para>
|
||||
/// </summary>
|
||||
/// <param name="Delayed">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public sealed record OptionGreeks(
|
||||
decimal? ImpliedVolatility,
|
||||
decimal? Delta,
|
||||
decimal? Gamma,
|
||||
decimal? Vega,
|
||||
decimal? Theta,
|
||||
decimal? OptionPrice,
|
||||
decimal? UnderlyingPrice,
|
||||
bool Delayed)
|
||||
{
|
||||
/// <summary>Ohne Delta und implizite Volatilität ist die Antwort für eine Auswahl unbrauchbar.</summary>
|
||||
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)" : "")}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zugang zu Optionsketten und Bewertungsgrößen. Getrennt von <see cref="IBrokerClient"/>, weil
|
||||
/// es hier weder um Handel noch um Bestand geht: Ein Modul, das nur Ketten liest, soll nicht
|
||||
/// nebenbei Orders auslösen können.
|
||||
/// </summary>
|
||||
public interface IOptionMarketData
|
||||
{
|
||||
/// <summary>
|
||||
/// Verfallstage und Strikes zu einem Basiswert. <c>null</c>, wenn der Broker nichts liefert –
|
||||
/// etwa weil auf den Wert keine Optionen gehandelt werden.
|
||||
/// </summary>
|
||||
Task<OptionChain?> GetChainAsync(InstrumentRef underlying, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Bewertungsgrößen eines konkreten Kontrakts. <c>null</c>, wenn der Broker nichts liefert.
|
||||
/// </summary>
|
||||
Task<OptionGreeks?> GetGreeksAsync(OptionSpec spec, CancellationToken ct = default);
|
||||
}
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 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. <b>Liest nur</b> – es wird nichts platziert.
|
||||
/// </summary>
|
||||
private static async Task<int> RunOptionChainAsync(
|
||||
IServiceProvider services, string symbol, string currency)
|
||||
{
|
||||
var market = services.GetRequiredService<IOptionMarketData>();
|
||||
var broker = services.GetRequiredService<IBrokerClient>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bestandsabgleich von Hand. Ohne <c>--dry-run</c> werden die Korrekturen gebucht;
|
||||
/// gehandelt wird in keinem Fall.
|
||||
|
||||
@@ -264,12 +264,14 @@ public static class AppHostBuilder
|
||||
services.AddSingleton<IbkrBrokerClient>();
|
||||
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||
services.AddSingleton<IOptionMarketData>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<NullBrokerClient>();
|
||||
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||
services.AddSingleton<IOptionMarketData>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||
}
|
||||
|
||||
// Core-Worker/Services
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user