Options levels (reference)

Reference

Options levels

The public surface of SabrTrader.Pipeline.Levels — gamma / options-levels feeds, snapshots, strike profiles, settings schema and the ambient registry. Ships in SabrTrader.Pipeline.Contracts (bundled by SabrTrader.Sdk).

Values & enums

public readonly record struct GammaLevel(
    GammaLevelType Type, double Price, int Rank, string? Label, string? VendorTag);

public sealed class GammaLevelSnapshot
{
    public required string Symbol { get; init; }
    public required string ProviderKey { get; init; }
    public required DateOnly SessionDate { get; init; }
    public required DateTimeOffset AsOfUtc { get; init; }
    public required IReadOnlyList<GammaLevel> Levels { get; init; }
    public GammaScalars? Scalars { get; init; }
    public double? UnderlyingPrice { get; init; }
}

public enum GammaLevelType
{
    CallResistance = 0, PutSupport = 1, HVL = 2,
    ZeroDteCallResistance = 3, ZeroDtePutSupport = 4, GexLevel = 5,
    ExpectedMoveHigh = 6, ExpectedMoveLow = 7, BlindSpot = 8,
    SwingLevel = 9, GammaScalpLevel = 10, VendorSpecific = 11,
}

public enum GammaRegime { Unknown = 0, Positive = 1, Negative = 2 }

public sealed class GammaScalars
{
    public double? Iv { get; init; }
    public double? IvRank { get; init; }
    public double? NetDex { get; init; }
    public double? NetGex { get; init; }
    public double? PutCallRatioOi { get; init; }
    public GammaRegime Regime { get; init; }
}

Feed & registry

public interface ILevelsFeed
{
    string ProviderKey { get; }
    string DisplayName { get; }
    LevelsCapabilities Capabilities { get; }
    int HistoryDays { get; }
    event Action<string>? SnapshotChanged;
    Task ConnectAsync(CancellationToken cancellationToken);
    Task DisconnectAsync();
    void Track(string symbol);
    GammaLevelSnapshot? GetSnapshot(string symbol);
    GammaLevelSnapshot? GetSnapshot(string symbol, DateOnly sessionDate);
    string? MapInstrument(string instrumentId);
}

public interface ILevelsRegistry
{
    IReadOnlyList<ILevelsFeed> Feeds { get; }
    event Action? FeedsChanged;
    ILevelsFeed? ResolveFeed(string instrumentId);
}

public static class LevelsAmbient
{
    public static ILevelsRegistry Registry { get; }
    public static void SetRegistry(ILevelsRegistry? registry);
    public static IDisposable Use(ILevelsRegistry registry);
}

[Flags]
public enum LevelsCapabilities
{
    None = 0, NamedLevels = 1, FuturesNative = 2, Intraday = 4, History = 8, StrikeProfile = 16,
    ExpirySelection = 32,
}

public sealed class NullLevelsRegistry : ILevelsRegistry
{
    public static NullLevelsRegistry Instance { get; }
    public IReadOnlyList<ILevelsFeed> Feeds { get; }
    public event Action? FeedsChanged;
    public ILevelsFeed? ResolveFeed(string instrumentId);
}

public static class LevelsSymbolMapping
{
    public static bool IsFuturesContract(string instrumentId);
    public static string? NormalizeToRoot(string instrumentId);
}

Building a levels venue

A levels vendor is a venue plugin like any other: VenueCategory.Levels on its manifest, VenueCapabilities.Levels declared, and one ILevelsFeed published as a session port. Because that shape is identical for every vendor, the SDK ships the session — LevelsVenueSession — so a levels plugin is a descriptor plus a feed and nothing else.

public sealed class LevelsVenueSession : IVenueSession
{
    public LevelsVenueSession(
        string displayName,
        Func<ILevelsFeed> feedFactory,          // called once per connect attempt
        IVenueNoticeSink? notices = null);

    public IDataProvider? Data => null;         // a levels venue serves no charts
    public ITradingProvider? Trading => null;   // …and routes no orders

    public T? GetPort<T>() where T : class;     // resolves ILevelsFeed while Connected
}
public IVenueSession CreateSession(VenueSessionContext context)
    => new LevelsVenueSession(
        displayName: "My Levels",
        feedFactory: () => new MyLevelsFeed(context.Settings["ApiKey"], context.Notices.Log),
        notices: context.Notices);
Two behaviours worth knowing. The feed is built INSIDE ConnectAsync, never at session creation — vendor feed constructors validate credentials and throw on a blank key, and the host must be able to create-and-abandon a session from any profile without a throw. And connect failures are reported NON-permanent: a levels vendor cannot distinguish a rejected key from a service hiccup, so the host's auto-retry keeps trying.

Strike profiles

public interface ILevelsStrikeProfile
{
    StrikeProfileSnapshot? GetStrikeProfile(string symbol);
}

public sealed class StrikeProfileSnapshot
{
    public required string Symbol { get; init; }
    public required string ProviderKey { get; init; }
    public required DateTimeOffset AsOfUtc { get; init; }
    public required IReadOnlyList<StrikeExposure> Strikes { get; init; }
    public double? UnderlyingPrice { get; init; }
}

public readonly record struct StrikeExposure(double Strike, double NetGex, double CallGex, double PutGex);

Expiration selection

Implement this when your vendor can compute its levels from a chosen subset of option expirations. A vendor that publishes pre-computed levels leaves it alone and changes nothing.
public interface ILevelsExpirySelection
{
    IReadOnlyList<DateOnly> GetAvailableExpiries(string symbol);
    IDisposable Track(string symbol, ExpirySelection selection);
    GammaLevelSnapshot? GetSnapshot(string symbol, ExpirySelection selection);
    StrikeProfileSnapshot? GetStrikeProfile(string symbol, ExpirySelection selection);
}

public enum ExpirySelectionKind
{
    VendorDefault = 0, AllExpiries, Nearest, WithinDays, MonthlyOpex, OnDate,
}

public readonly record struct ExpirySelection
{
    public ExpirySelectionKind Kind { get; }
    public int Days { get; }        // WithinDays
    public DateOnly Date { get; }   // OnDate

    public static ExpirySelection VendorDefault { get; }
    public static ExpirySelection AllExpiries { get; }
    public static ExpirySelection Nearest { get; }
    public static ExpirySelection ZeroDte { get; }        // WithinDays(0)
    public static ExpirySelection MonthlyOpex { get; }
    public static ExpirySelection WithinDays(int days);
    public static ExpirySelection On(DateOnly date);

    public IReadOnlyList<DateOnly> Resolve(IReadOnlyCollection<DateOnly> available, DateOnly session);
    public string ToToken();
    public static bool TryParse(string? token, out ExpirySelection selection);
}
Intent, not mechanism. A selection says WHICH boards the caller wants, never how you get them. A vendor whose endpoint already returns every expiration satisfies it by filtering what it holds; a vendor with a per-expiration endpoint satisfies it by fetching that slice. Do not assume fetching the whole chain is cheap for everyone.
Never invent your own rule. Resolve is the single implementation of what "0 DTE" or "monthly OPEX" means, and every feed calls it. Pass the session date in rather than reading a clock, so the answer stays deterministic. Expirations before the session never contribute.
Say what you actually produced. Stamp GammaLevelSnapshot.Expiry and StrikeProfileSnapshot.Expiry with the selection the numbers REALLY represent, not the one you were asked for. A feed that cannot slice reports VendorDefault and consumers then label the line plainly. Drawing a full-chain wall under a "0 DTE" label is a trading error, so the snapshot is the authority, not the caller. A selection you cannot satisfy publishes an EMPTY level set that still states itself — never substitute a neighbouring board. If you advertise StrikeProfile as well, the profile must honour the same selection, or a histogram will describe different boards than the lines drawn over it.
Track is the lifetime. Do the work a selection costs inside Track, on your own thread, and keep it warm until the handle is disposed. Both GetSnapshot overloads must stay lock-free and allocation-free enough to run on the chart render thread, so serve a precomputed projection rather than computing on the caller. Drop a projection once no handle holds it. Disposing twice, or after disconnect, is normal teardown.