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 how to test 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;

    // Why the last load produced no (or incomplete) entries, when the venue knows:
    // a 401, a truncated body. Null when empty is expected (still loading, search-only).
    string? LoadError => null;
}

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.

Set LoadError when a catalog load comes back empty or short for a reason you can name. The host logs it instead of a generic "listed no symbols" line, so an entitlement miss is easy to tell apart from a venue that really lists nothing.

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, without classification or 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. Implement ISearchOnlyUniverse on the data port and return true, and the host routes discovery through search.

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, such as 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; return null from TryGetAdjustmentVersion and skip the resolver.

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, so a malformed manifest fails here rather than 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 waits for the next connect. Rebuilding a plugin whose venue is currently connected parks the new generation until the venue next connects, so a live session keeps its socket owner.

Shipping the plugin

  • Ship the full closure. A venue's vendor dependencies are the complete transitive package set from its CopyLocalLockFileAssemblies bin, not a hand-written list. Native assets under runtimes\ and content files ship with it.
  • Vendor DLLs resolve through the plugin load context. A vendor DLL that ships next to your plugin is not in the app's deps.json; the plugin load context resolves it.
  • Deploy with the SDK target. The SabrTrader.Sdk package adds an opt-in post-build copy: set <DeploySabrTraderPlugin>true</DeploySabrTraderPlugin> and the built DLL and PDB land in Documents\SabrTrader\Plugins (override the folder with SabrTraderPluginsFolder). It copies your assembly only; vendor dependencies ship alongside it.

Testing your plugin

The host rejects or skips a plugin for a handful of mechanical reasons, and each one is cheap to pin in your venue's own test project. These are the checks worth writing before the first release; each exists because its failure costs a user something real.

Check What it prevents
The plugin type is public, concrete and has a parameterless constructor (Activator.CreateInstance succeeds) A plugin the loader cannot construct is skipped, and the venue never appears.
Manifest.Validate() passes for every descriptor, and TypeIds are unique A malformed manifest fails registration; a duplicate id collides in the registry.
Every released TypeId is still present, spelled exactly the same A renamed id orphans every saved profile of that venue.
Every schema field has a unique Key, every Choice field has choices or DynamicChoices, and no Secret is an identity field A broken schema produces a form the user cannot complete.
The plugin assembly references only the SDK contract packages An engine reference duplicates type identity under the plugin load context.
CreateSession, DisconnectAsync and DisposeAsync succeed on a session that never connected, with empty settings A session that throws on pre-connect disposal crashes the dialog's cleanup path.
On a connected session (against your own fake venue), each declared VenueCapabilities flag has its port, and no port exceeds the manifest An empty registration for a declared capability, or a port wider than the manifest.
MyVenuePluginTests.cspublic sealed class MyVenuePluginTests
{
    [Fact]
    public void Plugin_is_loadable_and_every_manifest_is_valid()
    {
        var plugin = (IVenuePlugin)Activator.CreateInstance(typeof(MyVenuePlugin))!;
        var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var venue in plugin.Venues)
        {
            venue.Manifest.Validate();
            Assert.True(ids.Add(venue.Manifest.TypeId), $"Duplicate TypeId {venue.Manifest.TypeId}");
        }
        Assert.Contains("MyVenue", ids);   // released ids never change
    }
}
Trading venues need more. A venue that publishes a trading port also has order-path promises to keep; Testing a trading port lists them.