Options: chain snapshots & analytics

Options data & trading

Chain snapshots & analytics

One call, one bounded, point-in-time capture of the chain with market data: contracts paired with IV, open interest, greeks and prices. The bulk input for exposure profiles (GEX/DEX/VEX), IV rank and screeners.

Snapshot vs. stream

Quote subscriptions are for a working set you keep on screen. Exposure analytics have the opposite shape: they want every relevant contract once, joined with its market data, at a single point in time. That is GetSnapshotAsync: the host fans out the venue calls, waits (bounded) for the data to land, and hands you the finished capture. No subscription management, no delta merging.

Capturing a snapshot

Snapshotusing SabrTrader.Pipeline.Options;

OptionChainSnapshot snap = await view.GetSnapshotAsync();   // null request = host defaults

Log($"{snap.UnderlyingSymbol} @ {snap.UnderlyingPrice?.ToString() ?? "?"}: " +
    $"{snap.Entries.Count} contracts across {snap.Expiries.Count} expiries");

foreach (OptionChainSnapshotEntry e in snap.Entries)
{
    OptionContract c = e.Contract;   // strike / right / expiry / multiplier
    OptionQuote    q = e.Quote;      // IV / OI / greeks / prices, nullable per field
}

Each entry pairs one OptionContract with the OptionQuote captured for it. Unlike a streamed update, a snapshot quote is a complete reading, but its fields are nullable for the same reason: a venue that does not serve greeks under this entitlement leaves them null. UnderlyingPrice is the spot at capture time when the venue reported one; check it for null before centering math.

Bounding the capture

Pass an OptionChainSnapshotRequest to widen or narrow the capture. Every field is optional; null means "the host's default for the serving venue". The host treats your values as bounds it clamps to what the venue can serve economically, not as a guarantee:

Bounded requestvar snap = await view.GetSnapshotAsync(new OptionChainSnapshotRequest
{
    MaxExpiries      = 8,                          // nearest 8 expiries
    MaxContracts     = 800,                        // centered on the money, both sides
    MinGreekCoverage = 0.9,                        // wait until 90% of entries carry greeks...
    GreekWaitTimeout = TimeSpan.FromSeconds(10),   // ...but no longer than this
});
Field Meaning
Expiries Exact expiries to capture. Null or empty: the venue's default expiry window, bounded by MaxExpiries.
MaxExpiries Cap on the number of expiries when Expiries is not given.
MaxContracts Cap on total contracts, centered on the underlying price (both sides of the money). Raise it for full-chain analytics like net GEX, which sums the entire chain. The host keeps a small near-the-money window per expiry (about 30 contracts) regardless, so values below that floor are clamped up.
MinGreekCoverage Fraction (0 to 1) of captured contracts that must carry greeks/IV before the snapshot returns. Applies on venues that stream greeks rather than serving them in bulk.
GreekWaitTimeout Longest the capture may wait for that coverage before returning what it has.
TradedContractsOnly A hint that only contracts with a trade today are needed: the shape of ticker-level analytics (ATM IV, put/call and volume/OI ratios, wing skew). A venue that can answer that from its trade tape in fewer calls skips the untraded strikes; other venues serve their normal capture. Leave it false for anything that needs the whole ladder (net GEX, max pain).
Wider captures cost venue calls and time. Request what the computation needs. A near-the-money IV smile needs one expiry and a few dozen strikes; a net-GEX profile needs the full chain of the front expiries. Those are different requests, not one big one.

Greek coverage on streaming venues

Some venues answer a snapshot request from a bulk endpoint, and greeks arrive with the chain in one response. Others serve greeks only over their streaming feed, so the host subscribes, waits for the data to flow in, captures, and unsubscribes. MinGreekCoverage and GreekWaitTimeout bound that wait. The snapshot returns when coverage is reached or the timeout expires, so check what you got before dividing by it:

Coverage checkint withGreeks = snap.Entries.Count(e => e.Quote.Gamma is not null);
double coverage = snap.Entries.Count == 0 ? 0 : (double)withGreeks / snap.Entries.Count;
if (coverage < 0.5)
    Log($"Thin greek coverage ({coverage:P0}): market closed or entitlement-limited feed.");
Off-hours captures are thin. Outside market hours many feeds serve stale or no greeks and little open interest. Report coverage alongside your result so the user can judge it.

Worked example: net GEX

Gamma exposure per contract is Gamma × OpenInterest × Multiplier × Spot² × 0.01 (dollar gamma per 1% move, one common convention), calls positive and puts negative under the usual dealer-positioning assumption. Summed per strike it gives the exposure profile; summed overall, net GEX:

NetGex.csvar snap = await view.GetSnapshotAsync(new OptionChainSnapshotRequest
{
    MaxExpiries = 4, MaxContracts = 1200,
    MinGreekCoverage = 0.8, GreekWaitTimeout = TimeSpan.FromSeconds(10),
});
if (snap.UnderlyingPrice is not decimal spot) return;        // no spot, no exposure math

var byStrike = new SortedDictionary<decimal, decimal>();
decimal netGex = 0m;

foreach (var e in snap.Entries)
{
    if (e.Quote.Gamma is not decimal gamma) continue;        // count coverage, don't invent zeros
    if (e.Quote.OpenInterest is not decimal oi || oi == 0m) continue;

    decimal exposure = gamma * oi * e.Contract.Multiplier * spot * spot * 0.01m;
    if (e.Contract.Right == OptionRight.Put) exposure = -exposure;

    byStrike[e.Contract.Strike] = byStrike.GetValueOrDefault(e.Contract.Strike) + exposure;
    netGex += exposure;
}

// byStrike plots as the exposure profile; the zero crossing is the flip level.
Log($"Net GEX {snap.UnderlyingSymbol}: {netGex:N0} $/1% across {byStrike.Count} strikes");
The same loop gives DEX and VEX. Swap Gamma for Delta (delta exposure; sign by right and the dealer convention you choose) or Vega (vega exposure); the capture, coverage and per-strike code stay the same. Rendering the profile on a chart is a normal custom-rendered indicator.

Expected move & implied spot

ImpliedMoveMath holds the expected-move arithmetic the platform's own chain window and gamma indicators use, so your plugin quotes the same band for the same board. IV goes in as a fraction (0.15 = 15%) and the day count is calendar days (DaysPerYear = 365), because implied volatility is quoted annualised on a calendar basis. Functions return NaN when an input is missing, so a board with no IV has no band rather than a zero-width one.

One-sigma band for an expiryusing SabrTrader.Pipeline.Options;

double years = ImpliedMoveMath.YearsUntilExpiry(expiry, DateTime.UtcNow);
double sigma = ImpliedMoveMath.OneSigma((double)spot, (double)atmIv, years);   // S * IV * sqrt(T)
var (high, low) = ImpliedMoveMath.Band((double)spot, sigma);

// The expected move is narrower than 1 sigma. Prefer the observed straddle when you have one:
double move = straddleMid is decimal s
    ? ImpliedMoveMath.ExpectedMoveFromStraddle((double)s)
    : ImpliedMoveMath.ExpectedMoveFromSigma(sigma);

YearsUntilExpiry measures to the settlement instant (SettlementUtc(expiry): 16:00 New York on the expiry date, which is 20:00 UTC in summer and 21:00 UTC in winter), so a same-day board still has hours of time value. YearsTo is the whole-day variant and YearsUntil takes two UTC instants. ExpectedMoveFromStraddle applies StraddleShare (0.85) to the ATM straddle; ExpectedMoveFromSigma scales a 1σ band by MeanAbsoluteDeviationRatio (√(2/π)) and the same share.

When no underlying quote is available (common outside market hours, when a fresh subscription on the cash index receives nothing), ChainImpliedSpot.Compute recovers a price from the board itself by put/call parity (S ≈ K + C − P) at the nearest expiry and the strike where |C − P| is smallest. The result is the forward, the price the options are priced off; present it as derived, and prefer a real quote whenever one exists.

ChainImpliedSpot// board: the OptionContracts of the front expiry; latest: your merged quotes by id.
decimal? Mid(OptionContract? c) =>
    c is not null && latest.TryGetValue(c.ProviderInstrumentId, out var q) ? q.Mid : null;

var points = new List<ChainImpliedSpot.ParityPoint>();
foreach (var strike in board.GroupBy(c => (c.Expiry, c.Strike)))
{
    decimal? callMid = Mid(strike.FirstOrDefault(c => c.Right == OptionRight.Call));
    decimal? putMid  = Mid(strike.FirstOrDefault(c => c.Right == OptionRight.Put));
    if (callMid is decimal cm && putMid is decimal pm)
        points.Add(new ChainImpliedSpot.ParityPoint(strike.Key.Expiry, strike.Key.Strike, cm, pm));
}
decimal? derivedSpot = ChainImpliedSpot.Compute(points);   // null when no usable pair exists

Compute needs at least one strike quoted on both sides and rejects a pair whose call/put gap is too wide to be a price (a stale or crossed closed-market quote).