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:
@@ -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 = "";
|
||||
|
||||
Reference in New Issue
Block a user