The plugin & manifest
The plugin & manifest INTERFACE
The declarative half of a venue: the one export the loader looks for, the manifest that renders its settings form, and the pre-connect behaviours the host asks for before anything connects.
On this page
The one export
The loader scans a plugin DLL for public, concrete, parameterless-constructible implementations
of IVenuePlugin and registers every venue each one describes. That is the entire
discovery contract — no attributes, no manifest file, no host edit.
IVenuePlugin.csnamespace SabrTrader.Pipeline.Venues;
public interface IVenuePlugin
{
// Called once at discovery. Must be fast, pure, and must never touch the network.
IReadOnlyList<IVenueDescriptor> Venues { get; }
}
The descriptor
One IVenueDescriptor is one venue row. Descriptors are stateless: they hold
the declarative manifest and build sessions, and all per-connection state lives on the sessions
they create. Only Manifest and CreateSession are required; the other
three members have defaults.
IVenueDescriptor.cspublic interface IVenueDescriptor
{
VenueManifest Manifest { get; }
// Build a fresh, DISCONNECTED session for one profile. Must not touch the
// network — connecting happens in IVenueSession.ConnectAsync.
IVenueSession CreateSession(VenueSessionContext context);
// Provider-judged validation of raw form values, for what only the venue can
// judge. Pure, fast (runs on the UI thread), must not throw. Default accepts.
bool TryValidateSettings(IReadOnlyDictionary<string, string> values, out string? error);
// Live options for a schema field flagged DynamicChoices. Must be self-contained
// on failure — return the static list rather than throw, so the form stays usable
// offline. Default returns the field's static choices.
Task<IReadOnlyList<VenueChoice>> GetDynamicChoicesAsync(
string fieldKey,
IReadOnlyDictionary<string, string> currentValues,
CancellationToken cancellationToken);
// The venue's symbol grammar, composed from the platform's shared parsers.
// Default null = no venue-specific grammar.
IVenueSymbolParser? CreateSymbolParser(ISharedSymbolParsers shared);
}
The manifest
Everything the host can know before a session exists. Pure data, validated at plugin install so a malformed manifest fails at registration rather than at dialog render or connect.
| Member | What it drives |
|---|---|
TypeId |
The connection-profile key, the provider key, the registry key. Compared case-insensitively. Verbatim forever — a venue migrating from an older shape keeps the id it already had. |
DisplayName |
The label in the Connections dialog. |
Category |
MarketData (grouped by asset class) or Levels (its own section). |
AssetClasses |
The venue lists under every class named here. Empty is invalid — use
VenueAssetClass.Unknown when a venue genuinely cannot classify itself. |
Capabilities |
The declared superset (see the two-layer rule). Drives dialog grouping, catalog-sync scheduling, and chain / blotter / levels registration. |
SettingsSchema |
The fields the host's generic connection form renders. Secrets are encrypted and decrypted by the HOST. |
SupportsPaper |
The venue offers a paper/demo endpoint. |
MaxConcurrentSessions |
1 (default) = connecting a second profile replaces the first. >1 = the host keeps an independent session per profile (prop-firm double logins, live+demo). The host owns multiplicity; the venue states its limit. |
FallbackPriority |
Sort band when several venues can serve the same symbol; lower wins. See
VenuePriorityBands (Primary 100 · Default 500 ·
Fallback 900). |
StateStorageFolder |
Relative folder under the host's state root for this venue's state files. Null = the lower-cased TypeId. A migrating venue names its LEGACY folder here so existing state files keep working. |
AttributionBadge |
Identifier of license-mandated vendor attribution artwork the host must show while a session is live ("rithmic"). Null = none. |
The settings schema
Each ProviderCredentialField is one input row in the generated form. Kind
drives rendering — Secret is masked, Url validates shape,
Toggle becomes a checkbox, Choice a dropdown — and the field's
Key is the dictionary key your session reads back from
context.Settings.
ProviderCredentialField.csnamespace SabrTrader.Pipeline.Providers;
public sealed record ProviderCredentialField(
string Key,
string DisplayName,
ProviderCredentialKind Kind, // Plain | Secret | Choice | Toggle | Url
string? Placeholder = null,
bool Required = true,
IReadOnlyList<string>? Choices = null,
string? DefaultValue = null,
string? HelpText = null, // sub-label under the input
string? WarningText = null, // for risky options (TLS bypass)
bool Multiline = false, // a Secret that spans lines (a PEM block)
bool DynamicChoices = false, // options loaded at runtime; Choices is the offline fallback
bool ReadOnly = false, // filled but non-editable
string? StorageName = null); // the JSON property this field persists under
Byte-compatible storage names
StorageName is what lets a venue move onto the seam without breaking a single saved
profile. The host's generic settings marshalling writes each field under its Key by
default; give a StorageName and it writes under that JSON property instead —
including dotted paths, so a nested legacy shape survives verbatim.
| StorageName | Persisted as |
|---|---|
null (default) |
Under the field's Key. Secrets as a host-encrypted blob, plain values verbatim. |
"ProtectedApiKeyBase64" |
A flat legacy property name. |
"PlainValues.Environment" |
Nested under the legacy PlainValues object. |
"GatewaySecretsBase64.refresh_token" |
Nested under the legacy encrypted-secrets bag — the landing place for rotating OAuth tokens. |
Validation & dynamic choices
Per-field required-ness is already covered by the schema, so
TryValidateSettings is for what only the venue can judge: a credential in a
shape its client cannot use, a combination of fields that cannot both be set. It runs on the UI
thread before save and connect, so it must be pure, fast, and must never throw.
GetDynamicChoicesAsync fills a Choice field flagged
DynamicChoices from the live venue — the account list behind a token, the
environments a login can reach. It receives the form's current values, because one field may
depend on another. Its one hard rule: never throw. Return the static fallback list, so a
user editing a profile offline still sees a usable form.
Descriptor.cspublic bool TryValidateSettings(IReadOnlyDictionary<string, string> values, out string? error)
{
if (values.TryGetValue(FieldEndpoint, out var url) &&
!Uri.TryCreate(url, UriKind.Absolute, out _))
{
error = "Endpoint must be an absolute URL, e.g. https://api.example.com.";
return false;
}
error = null;
return true;
}
public async Task<IReadOnlyList<VenueChoice>> GetDynamicChoicesAsync(
string fieldKey, IReadOnlyDictionary<string, string> currentValues, CancellationToken ct)
{
if (fieldKey != FieldAccount) return VenueChoice.StaticChoicesFor(Manifest, fieldKey);
try
{
var accounts = await MyRestClient.ListAccountsAsync(currentValues[FieldToken], ct);
return accounts.Select(a => new VenueChoice(a.Label, a.Id)).ToList();
}
catch
{
// Self-contained on failure — the form stays usable offline.
return VenueChoice.StaticChoicesFor(Manifest, fieldKey);
}
}
The symbol grammar
CreateSymbolParser teaches the host what a raw symbol means on this venue —
consulted for typed input (the New Chart box) and for catalog classification. It runs per
keystroke, so it must be pure, fast and allocation-light, and it must never throw: unparseable
input returns VenueSymbolParse.Unrecognized.
Compose, do not re-implement. The ISharedSymbolParsers handed in carries the
platform's proven grammars — CME-style futures (month codes, digit-bearing roots, year
disambiguation), US equity tickers, separated FX pairs behind the ISO-4217 whitelist, and the
shared crypto grammar. Write only the part that is genuinely yours: a prefix convention, a
contributor suffix, the split between the asset classes your venue mixes.
MyVenueSymbols.cspublic IVenueSymbolParser? CreateSymbolParser(ISharedSymbolParsers shared)
=> new MyVenueSymbolParser(shared);
internal sealed class MyVenueSymbolParser(ISharedSymbolParsers shared) : IVenueSymbolParser
{
public VenueSymbolParse Parse(string rawSymbol)
{
// Venue convention: "F:" prefixes a futures contract, everything else is equity.
if (rawSymbol.StartsWith("F:", StringComparison.Ordinal))
return shared.Futures.Parse(rawSymbol[2..]);
return shared.Equities.Parse(rawSymbol);
}
}
A complete descriptor
Polygon.io, the seam's reference venue — one credential, one choice field, a legacy storage name, and honest capabilities:
PolygonVenuePlugin.cspublic sealed class PolygonVenuePlugin : IVenuePlugin
{
public IReadOnlyList<IVenueDescriptor> Venues { get; } = new IVenueDescriptor[]
{
new PolygonVenueDescriptor(),
};
}
internal sealed class PolygonVenueDescriptor : IVenueDescriptor
{
// TypeId verbatim from the legacy connection provider — saved profiles key on it.
internal const string TypeId = "Polygon";
internal const string FieldApiKey = "ApiKey";
internal const string FieldFeed = "Feed";
public VenueManifest Manifest { get; } = new(
TypeId: TypeId,
DisplayName: "Polygon.io",
Category: VenueCategory.MarketData,
AssetClasses: new[]
{
VenueAssetClass.Stock, VenueAssetClass.Future, VenueAssetClass.Forex,
VenueAssetClass.Crypto, VenueAssetClass.Index,
},
Capabilities: VenueCapabilities.LiveTrades
| VenueCapabilities.HistoricalBars
| VenueCapabilities.HistoricalTicks
| VenueCapabilities.InstrumentCatalog
| VenueCapabilities.SymbolSearch,
SettingsSchema: new[]
{
new ProviderCredentialField(FieldApiKey, "API Key",
ProviderCredentialKind.Secret,
Required: true,
HelpText: "Your personal Polygon.io API key. Leave blank when editing to keep " +
"the saved key.",
// Legacy persisted shape: the key stores under this JSON property.
StorageName: "ProtectedApiKeyBase64"),
new ProviderCredentialField(FieldFeed, "Live feed",
ProviderCredentialKind.Choice,
Required: false,
Choices: new[] { "Auto", "Real-time", "Delayed (15-min)" },
DefaultValue: "Auto",
HelpText: "Auto tries the real-time feed and falls back to delayed per asset " +
"class when the plan isn't entitled."),
});
public IVenueSession CreateSession(VenueSessionContext context)
=> new PolygonVenueSession(context);
}