Instruments & discovery

Venue plugins

Instruments & discovery

Tick sizing and point values, the typed instrument catalog, live symbol search, how the loader finds your plugin, and the conformance kit that gates it.

Instrument metadata

IInstrumentMetadata (namespace SabrTrader.Pipeline.Bars) is how the platform learns the numbers behind an instrument: tick size, point value, quantity unit and step, quote currency, price precision. Get tick size wrong and every chart, ladder and PnL figure for that instrument is wrong with it.

Most venues never implement the interface by hand — hydrate the shipped InMemoryInstrumentMetadata from your reference data as it arrives, and publish it as IDataProvider.Instruments.

IInstrumentMetadata (the members most venues fill)double  GetTickSize(string instrumentId);
bool    IsRegistered(string instrumentId);
double? TryGetPointValue(string instrumentId);
QuantityUnit GetQuantityUnit(string instrumentId);      // Contract | Share | Unit …
double? TryGetLotSize(string instrumentId);
double? TryGetQuantityStep(string instrumentId);
double? TryGetMinQuantity(string instrumentId);
int?    TryGetPriceSignificantFigures(string instrumentId);
string? TryGetQuoteCurrency(string instrumentId);
InstrumentCategory? TryGetCategory(string instrumentId);

The typed catalog

IVenueInstrumentCatalog is THE surface for "what instruments does this venue have", probed on the data port with AsCapability<IVenueInstrumentCatalog>(). It serves both consumers: the persistent symbol-catalog sync and the live exchange-browse listing in the symbol picker.

IVenueInstrumentCatalog.cspublic interface IVenueInstrumentCatalog
{
    // May be empty right after connect and GROW as background loads land.
    IReadOnlyList<VenueCatalogEntry> ListEntries();

    // Raised when the entry set changes. May fire on any thread; the host marshals.
    event Action? EntriesChanged;
}

public readonly record struct VenueCatalogEntry(
    string InstrumentId,                // the venue's raw id ("ESU6", "AAPL")
    VenueAssetClass AssetClass,         // the class the VENUE lists it under
    string? Exchange = null,            // catalog identity ("GLBX", "XNAS")
    string? Description = null,
    string? ProductCode = null,         // product root ("MES") — browse groups by it
    string? InstrumentType = null,      // the venue's own label ("Future"), display-only
    string? Expiration = null);         // as the venue reports it ("20260918")

Typed entries are the authoritative form, because asset class often cannot be inferred from the symbol string. A dual-dataset venue legitimately lists ES both as a futures root and as an equity ticker, and only the venue knows which catalog each occurrence came from. Give ids listed under several classes distinct Exchange codes so their catalog identities never collide.

Author this catalog. It is what gives every instrument a stable typed identity across the platform — the symbol catalog, the picker and the browse listing all read it. Declare VenueCapabilities.InstrumentCatalog and the host schedules the symbol-catalog sync against it.

Flat id listing

IInstrumentDirectory.ListInstruments() returns a venue's instrument ids as a flat list. That is what the symbol picker reads to show everything a venue carries, and it is the right surface for exactly that job — a list of strings, no classification, no identity.

IVenueInstrumentClassifier pairs with it: given one raw id, it returns the asset class and symbol the venue means by it. Author your catalog as typed VenueCatalogEntry values and both questions are already answered — use the classifier when a venue's universe is genuinely a flat id list and classification is derivable per id.

IVenueInstrumentClassifier.cspublic interface IVenueInstrumentClassifier
{
    VenueInstrumentClassification? Classify(string instrumentId);
}

public readonly record struct VenueInstrumentClassification(
    VenueAssetClass AssetClass, string Symbol);

IRemoteSymbolSearch (probed with AsCapability<T>(), declared as VenueCapabilities.SymbolSearch) backs the symbol picker's live search box and the resolve of a symbol the user typed. Return the metadata you already know on each hit — the host adopts it rather than issuing a second round trip.

IRemoteSymbolSearch.cspublic interface IRemoteSymbolSearch
{
    Task<IReadOnlyList<RemoteSymbolHit>> SearchAsync(string query, CancellationToken ct = default);
    Task<RemoteSymbolHit?> ResolveAsync(string symbol, CancellationToken ct = default);
}

public sealed record RemoteSymbolHit(
    string Symbol,
    string? Exchange = null,
    string? Description = null,
    InstrumentCategory? Category = null,
    QuantityUnit? QuantityUnit = null,
    double? TickSize = null,
    double? PointValue = null,
    double? LotSize = null,
    double? QuantityStep = null,
    double? MinQuantity = null,
    string? QuoteCurrency = null);

Search-only universes

Some venues cannot enumerate their universe at all — the only way in is a query. Declare that with ISearchOnlyUniverse so the host stops expecting an enumerable catalog and routes discovery through search instead.

ISearchOnlyUniverse.cspublic interface ISearchOnlyUniverse
{
    bool UniverseIsSearchOnly { get; }
}

Corporate actions

IInstrumentMetadata.TryGetAdjustmentVersion is the stamp that keeps split- and dividend-adjusted history out of a stale cache: change the stamp and the cache key changes with it. It is synchronous, but on some venues the version is only knowable by an async per-symbol lookup — an equity broker's corporate-actions endpoint, with no bulk list. That is what IInstrumentAdjustmentResolver is for: the async companion that resolves and caches the version BEFORE it is read into a cache key.

IInstrumentAdjustmentResolver.csnamespace SabrTrader.Pipeline.Storage;

public interface IInstrumentAdjustmentResolver
{
    // Ensure this instrument's adjustment version is resolved + cached, so the
    // synchronous TryGetAdjustmentVersion that follows returns the real stamp.
    Task EnsureAdjustmentVersionAsync(string instrumentId, CancellationToken ct = default);
}
Only equity-style venues need it. A futures or crypto venue has no corporate actions; leave both the stamp and the resolver unimplemented and nothing in the chain changes.

How the host finds your plugin

  1. The plugin loader scans plugin assemblies and instantiates every public, concrete, parameterless-constructible IVenuePlugin.
  2. Each descriptor's Manifest.Validate() runs at registration — a malformed manifest fails here, not at dialog render.
  3. Every venue is registered into the connection registry under its TypeId, and appears in the Connections dialog under its category and asset classes.
  4. When the user saves a profile, the host builds a VenueSessionContext and calls CreateSession; connecting is a separate step.
Hot reload never swaps a connected venue. Rebuilding a plugin whose venue is currently connected parks the new generation until the next connect. That is deliberate: swapping a live socket owner mid-session is not a thing anyone wants.

Shipping the plugin

  • Ship the full closure. A venue's vendor dependencies are the complete transitive package set from its CopyLocalLockFileAssemblies bin — never a hand-written list. Native assets under runtimes\ and content files ship with it.
  • Vendor sidecars resolve through the load context. A plugin's vendor DLL sitting in the app root is not in the app's deps.json; the plugin load context's last-chance resolve hook serves it. Every vendor-package venue relies on that.
  • Build with the app running. Pass -p:SkipPluginDeploy=true when an app instance may hold the plugin DLL — never kill the process to win a file lock.

The conformance kit

VenuePluginConformanceKit is the per-venue merge gate. Subclass it in your venue's test project and the seam checks come for free. Each check exists because its failure costs a customer something real.

MyVenuePluginConformanceTests.cspublic sealed class MyVenuePluginConformanceTests : VenuePluginConformanceKit
{
    protected override IVenuePlugin CreatePlugin() => new MyVenuePlugin();

    // The ids this venue carried BEFORE the seam. Saved profiles key on them.
    protected override IReadOnlyList<string> LegacyTypeIds => new[] { "MyVenue" };
}
Check What it prevents
Plugin type is loader-compatible A non-public / abstract / ctor-less plugin is silently skipped and the venue never appears.
Manifests valid, TypeIds unique A malformed manifest never renders a settings form; a duplicate id collides in the registry.
Legacy TypeIds preserved verbatim A renamed id orphans every saved profile of that venue.
Settings schema well-formed A broken schema produces a form the user cannot complete.
Assembly is contracts-only An engine reference duplicates type identity under the plugin load context.
Create / disconnect / dispose safe offline A session that throws on pre-connect disposal crashes the dialog's cleanup path.
AssertPortsMatchCapabilities (from your own connected-fake harness) A declared capability whose port is absent — or a port wider than the manifest.
Trading venues run a second kit. Any venue publishing a trading port is additionally gated by TradingProviderContractKit — see Contract kit & capabilities.