The venue session

Venue plugins

The venue session INTERFACE

One live connection profile: the lifecycle owner of everything your venue runs, the composition root for its ports, and the one place per-connection state is allowed to live.

The contract

One session serves one connection profile. It owns everything the venue runs for that profile — sockets, pumps, caches, background loops — and it is the composition root for the venue's ports. A session is single-use: the host creates a fresh one per connect attempt, and a session that failed to connect is disposable but not reusable.

IVenueSession.csnamespace SabrTrader.Pipeline.Venues;

public interface IVenueSession : IAsyncDisposable
{
    ProviderConnectionStatus Status { get; }
    event Action<ProviderConnectionStatus>? StatusChanged;   // may fire on venue threads

    VenueFailure? LastFailure { get; }

    IDataProvider?    Data    { get; }    // null before connect / after disconnect / data-less venue
    ITradingProvider? Trading { get; }    // same nullability contract

    event Action<VenueStreams>? StreamsRestored;

    Task ConnectAsync(CancellationToken cancellationToken);
    Task DisconnectAsync();               // idempotent, never throws, safe at any state

    // Optional session ports. The default probes the session object itself; override
    // when the port is a separately-owned object created in ConnectAsync.
    T? GetPort<T>() where T : class => this as T;
}

Connect, fail, disconnect

ConnectAsync brings up all of the venue's surfaces for the profile and throws on failure. DisconnectAsync tears everything down; it is idempotent, never throws, and is safe to call on a session that never connected — the host's cleanup paths depend on that, including the connection dialog's cancel path.

PolygonVenueSession.csinternal sealed class PolygonVenueSession(VenueSessionContext context) : IVenueSession
{
    private PolygonDataProvider? _provider;
    private int _status;

    public ProviderConnectionStatus Status => (ProviderConnectionStatus)Volatile.Read(ref _status);
    public event Action<ProviderConnectionStatus>? StatusChanged;
    public event Action<VenueStreams>? StreamsRestored { add { } remove { } }
    public VenueFailure? LastFailure { get; private set; }

    public IDataProvider? Data => _provider;
    public ITradingProvider? Trading => null;

    public async Task ConnectAsync(CancellationToken cancellationToken)
    {
        SetStatus(ProviderConnectionStatus.Connecting);
        context.Notices.Log("Connecting…");

        var provider = new PolygonDataProvider(
            new PolygonProviderOptions
            {
                ApiKey = context.Settings[PolygonVenueDescriptor.FieldApiKey],
                // Venue state rides the host's storage — never a hand-built file path.
                LoadFuturesSnapshot = () => context.Storage.ReadText("known-futures.json"),
                SaveFuturesSnapshot = json => context.Storage.WriteText("known-futures.json", json),
                Log = context.Notices.Log,
            },
            backfillComposer: context.BackfillComposer);

        try
        {
            await provider.ConnectAsync(cancellationToken).ConfigureAwait(false);
            _provider = provider;
            SetStatus(ProviderConnectionStatus.Connected);
            context.Notices.Log("Connected.");
        }
        catch (Exception ex)
        {
            try { provider.Dispose(); } catch { /* best-effort */ }
            bool permanent = ex is PolygonAuthException;      // a rejected key cannot be retried
            LastFailure = new VenueFailure($"Polygon.io connect failed: {ex.Message}", permanent);
            SetStatus(ProviderConnectionStatus.Failed);
            throw new InvalidOperationException(LastFailure.Message, ex);
        }
    }

    public async Task DisconnectAsync()
    {
        var provider = _provider;
        _provider = null;
        if (provider is not null)
        {
            try { await provider.DisconnectAsync().ConfigureAwait(false); }
            catch { /* no-throw teardown contract */ }
            try { provider.Dispose(); } catch { /* best-effort */ }
        }
        SetStatus(ProviderConnectionStatus.Disconnected);
    }

    public async ValueTask DisposeAsync() => await DisconnectAsync().ConfigureAwait(false);

    private void SetStatus(ProviderConnectionStatus next)
    {
        var prev = (ProviderConnectionStatus)Interlocked.Exchange(ref _status, (int)next);
        if (prev != next) StatusChanged?.Invoke(next);
    }
}

Reporting failure

VenueFailure is failure as data, and its Permanent flag is the one field that changes host behaviour: true means retrying the same profile cannot succeed — bad credentials, revoked entitlement, an unsupported account — so the supervisor stops retrying and surfaces the message instead of hammering the venue forever. Everything else is transient and keeps its auto-retry.

VenueFailure.cspublic sealed record VenueFailure(string Message, bool Permanent, string? Code = null);
Be honest about permanence. Marking a transient outage permanent leaves the user disconnected until they notice; marking a rejected key transient produces an endless retry storm against the venue. If your venue genuinely cannot tell the two apart — most levels vendors cannot — report non-permanent and let auto-retry do its job.

Notices & the activity log

context.Notices exists from construction, so there is no "built without a log sink" window. It is non-blocking and never throws: a stalled UI can never backpressure a venue thread through it.

  • Log(string) appends to the venue's user-visible activity log. "Connecting…", "Connected.", "Subscribed to 42 instruments." Keep it meaningful — developer tracing does not belong here.
  • Post(VenueNotice) raises a user-facing notice with a severity, an optional Symbol when it is instrument-scoped, and an optional machine-readable Code. Two codes are well-known and the host reacts to them structurally: VenueNoticeCodes.EntitlementDenied and VenueNoticeCodes.DelayedData.
Notices.cscontext.Notices.Post(new VenueNotice(
    VenueNoticeSeverity.Warning,
    $"{market} live feed unavailable on this plan ({message}); that asset class charts " +
    "from history only.",
    Code: VenueNoticeCodes.EntitlementDenied));
Write the message the user needs. "API key rejected — verify it in the portal" is a notice. "HTTP 401" is not. Notice text is the venue's only voice in the product.

Venue state files

context.Storage holds small venue-scoped state — a known-instruments snapshot, a cursor, a learned entitlement verdict. The host roots the files per venue (see VenueManifest.StateStorageFolder), so venues never build filesystem paths, and writes are best-effort: state files are treated as reconstructible and a transient IO failure must not throw at you.

There are two lanes. The default lane rides the host's persistence backend, which may sync state across the user's machines. The LOCAL lane is guaranteed machine-local — use it for multi-megabyte rebuildable caches (instrument-definition snapshots, learned denials) that must never bloat a synced state snapshot.

IVenueStateStorage.cspublic interface IVenueStateStorage
{
    string? ReadText(string name);
    void WriteText(string name, string content);

    string? ReadLocalText(string name);              // never state-synced
    void WriteLocalText(string name, string content); // for large rebuildable caches
}

Rotating credentials

A venue whose tokens rotate server-side (any OAuth refresh flow) writes the new values back through context.Credentials. Values are plaintext keyed by schema field key; the host encrypts and merges them into the profile. Point the rotating fields at a StorageName under the legacy secrets bag and the refreshed token lands exactly where the old stack put it.

Refresh.cs// After a successful token refresh:
context.Credentials.Persist(new Dictionary<string, string>
{
    [FieldAccessToken]  = tokens.AccessToken,
    [FieldRefreshToken] = tokens.RefreshToken,
});
Silently dropped tokens are the classic bug. A venue that refreshes in memory but never calls Persist works perfectly until the app restarts, then forces a re-login every time. Persist on every rotation.

Stream restoration

When the venue re-establishes its own transport without a full session cycle, raise StreamsRestored with the groups affected. The host reacts per flag: Trading triggers a snapshot reconcile, MarketData triggers feed re-registration plus gap backfill. A venue whose transport self-heals invisibly (per-cluster sockets that resubscribe themselves) can leave the event unraised.

VenueStreams.cs[Flags]
public enum VenueStreams { None = 0, MarketData = 1, Trading = 2, Account = 4 }

// After the order + account sockets came back:
StreamsRestored?.Invoke(VenueStreams.Trading | VenueStreams.Account);

Optional ports

Anything that is not the data or trading plane is a session port. Consumers call session.AsPort<T>(), which walks IVenueSessionDecorator chains and asks each level's GetPort<T>, so both directly-implemented and separately-owned ports resolve.

The default GetPort<T> probes the session object itself — implement the port on your session and you are done. Override it when the port is a separately-owned object, and return it only while connected, so a consumer can never hold a feed whose lifecycle has ended:

GetPort.cs// Directly implemented: nothing to write.
internal sealed class MyVenueSession : IVenueSession, IVenueTradeHistorySource { … }

// Separately owned: expose it only while Connected.
public T? GetPort<T>() where T : class
    => (this as T) ?? (Volatile.Read(ref _chainSource) as T);
The host registrar owns registration. You expose the port; the host's VenueSessionPortRegistrar registers it after connect and unregisters it BEFORE teardown. Declare the matching VenueCapabilities flag (OptionChains, TradeHistoryBackfill, CashActivity, Levels) so the registrar knows to look.

Native IV history

An options venue that serves its own per-session implied-volatility series publishes it through context.IvHistory. That seeds the host's IV-history store, so IV Rank shows a full look-back the first time a chain opens instead of accruing daily snapshots for weeks. A venue without a native series simply never calls it, and the sink is null on hosts that keep no IV history.

IVenueIvHistorySink.cspublic interface IVenueIvHistorySink
{
    // Idempotent per session host-side; never throws — a seeding failure must not
    // fail a chain fetch.
    void Publish(string underlyingSymbol, IReadOnlyList<VenueIvHistoryPoint> points);
}

// Iv30 is the 30-day-maturity IV as a FRACTION (0.256 = 25.6%), the platform's unit.
public readonly record struct VenueIvHistoryPoint(DateOnly Session, double Iv30);
Seeding on connectcontext.IvHistory?.Publish("SPX", history
    .Select(p => new VenueIvHistoryPoint(p.SessionDate, p.Iv30 / 100d))   // vendor sends percent
    .ToList());

Levels venues

An options-levels vendor (gamma levels, GEX) is a venue too — VenueCategory.Levels, its own section in the Connections dialog, no charts of its own. It publishes exactly one thing: an ILevelsFeed as a session port. Because that shape is identical for every vendor, the SDK ships the session: LevelsVenueSession.

A complete levels venueinternal sealed class MyLevelsDescriptor : IVenueDescriptor
{
    public VenueManifest Manifest { get; } = new(
        TypeId: "MyLevels",
        DisplayName: "My Levels",
        Category: VenueCategory.Levels,
        AssetClasses: new[] { VenueAssetClass.Index, VenueAssetClass.Stock },
        Capabilities: VenueCapabilities.Levels,
        SettingsSchema: new[]
        {
            new ProviderCredentialField("ApiKey", "API key", ProviderCredentialKind.Secret),
        });

    public IVenueSession CreateSession(VenueSessionContext context)
        => new LevelsVenueSession(
            displayName: "My Levels",
            // Built INSIDE ConnectAsync, once per attempt: vendor feed constructors
            // validate credentials, and the host must be able to create-and-abandon
            // a session from any profile without a throw.
            feedFactory: () => new MyLevelsFeed(context.Settings["ApiKey"], context.Notices.Log),
            notices: context.Notices);
}

The shipped session owns the lifecycle for you: the feed is built inside ConnectAsync, exposed through GetPort<ILevelsFeed>() only while Connected, and torn down on disconnect — so a consumer can never hold a feed whose lifecycle has ended. Data and Trading are always null.

Connect failures are reported non-permanent. A levels vendor cannot tell a rejected key from a service hiccup, so LevelsVenueSession never marks a failure permanent and the host's auto-retry keeps trying — the behaviour the bespoke levels providers always had. The feed contract itself (ILevelsFeed, snapshots, strike profiles) is on the Options levels reference.

The data port

IDataProvider (namespace SabrTrader.Pipeline.Providers) is the venue's market-data face: one object bundling backfill, live ticks, instrument metadata, depth and the optional feeds. Its sub-surfaces are nullable, and the presence of a non-null property is part of the contract for the matching ProviderCapabilities flag.

IDataProvider.cs (abridged)public interface IDataProvider : IDisposable
{
    string Key { get; }                     // matches the venue TypeId
    string DisplayName { get; }
    ProviderCapabilities Capabilities { get; }

    IBackfillProvider?   Backfill    { get; }   // flag: Backfill
    ITickSource?         LiveTicks   { get; }   // flag: LiveTicks
    IInstrumentMetadata? Instruments { get; }   // flag: InstrumentMetadata
    IMarketDepthFeed?    Depth       { get; }   // flag: OrderbookDepth
    IMarketByOrderFeed?  Mbo         { get; }   // flag: OrderbookDepth

    IOpenInterestFeed?  OpenInterest  { get; }  // optional, no flag
    IMarketSummaryFeed? MarketSummary { get; }  // optional, no flag
    IFundamentalsFeed?  Fundamentals  { get; }  // optional, no flag

    bool SupportsBarSpec(BarSpecification spec);

    ProviderConnectionStatus Status { get; }
    event Action<ProviderConnectionStatus>? ConnectionStatusChanged;
    event Action? MarketDataFeedsChanged;       // Depth/Mbo may appear a beat after Connected

    Task ConnectAsync(CancellationToken cancellationToken = default);
    Task DisconnectAsync();
}

Optional facets of the data plane are not properties on this interface — they are probed with AsCapability<T>() (walking IDataProviderDecorator chains), so the interface stays stable as facets are added: IVenueInstrumentCatalog, IRemoteSymbolSearch, ISearchOnlyUniverse, IContinuousFuturesSource.

MarketDataFeedsChanged is not optional decoration. If your Depth or Mbo property can transition null → non-null after Connected — an asynchronous aggregator determination, a reconnect re-establishing feeds — you must raise it. A consumer that read null at construction and never hears otherwise stays deaf to the order book for the rest of the session.

Threading contract

  • StatusChanged, StreamsRestored and notices MAY fire on venue threads. The host marshals to the UI — do not marshal yourself, and do not hold a lock across the raise.
  • The venue owns its wire and pump threads and must never block inside a host callback. Queue and return.
  • In-session transport retries are venue-internal and must be storm-guarded. Profile-level reconnect cycling is the host supervisor's job, not yours.
  • A stalled venue must never backpressure the platform's sequencers; the ingest plane's drop semantics absorb overload (see the perf contract).
  • The host serializes Connect/Disconnect per profile. Your one obligation is an idempotent DisconnectAsync.