Instruments & discovery
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.
On this page
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.
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);
Search & resolve
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);
}
TryGetAdjustmentVersion and skip the resolver.How the host finds your plugin
- The plugin loader scans plugin assemblies and instantiates every public, concrete,
parameterless-constructible
IVenuePlugin. - Each descriptor's
Manifest.Validate()runs at registration, so a malformed manifest fails here rather than at dialog render. - Every venue is registered into the connection registry under its
TypeId, and appears in the Connections dialog under its category and asset classes. - When the user saves a profile, the host builds a
VenueSessionContextand callsCreateSession; connecting is a separate step.
Shipping the plugin
-
Ship the full closure. A venue's vendor dependencies are the complete transitive package
set from its
CopyLocalLockFileAssembliesbin, not a hand-written list. Native assets underruntimes\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.Sdkpackage adds an opt-in post-build copy: set<DeploySabrTraderPlugin>true</DeploySabrTraderPlugin>and the built DLL and PDB land inDocuments\SabrTrader\Plugins(override the folder withSabrTraderPluginsFolder). 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
}
}