The venue session
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.
On this page
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);
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 optionalSymbolwhen it is instrument-scoped, and an optional machine-readableCode. Two codes are well-known and the host reacts to them structurally:VenueNoticeCodes.EntitlementDeniedandVenueNoticeCodes.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));
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,
});
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);
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.
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,StreamsRestoredand 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.