Options: chain snapshots & analytics
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.
On this page
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). |
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.");
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");
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).