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; }
}
Activator.CreateInstance and calls
Validate() on every manifest catches both mistakes before release.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. Fixed for the life of the venue: saved profiles key on it, so a released id never changes. |
DisplayName |
The label in the Connections dialog. |
Category |
MarketData (grouped by asset class), Levels (an analytics or levels vendor) or
Decisions (a decision endpoint with no market data). The last two render as their own
sections. |
AssetClasses |
The venue lists under every class named here. Empty is invalid for a MarketData
venue; 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. Forward slashes for subfolders; never rooted and never ... Name an existing
folder here to keep reading state files a venue already wrote under another name. |
AttributionBadge |
Identifier of license-mandated vendor attribution artwork the host must show while a session is
live ("rithmic"). Null = none. Attribution that belongs to the SESSION rather than the venue goes
through IVenueSessionAttribution. |
AccountIdsIdentifyUser |
True when the account ids this venue reports after a connect are assigned by the venue to ONE customer (a Rithmic account number), so the host may treat them as the user's identity for trial-farming detection. False (the default) for venues whose ids are product constants or shared labels, such as a crypto venue's "spot"/"perp" portfolio or a demo account name. Those identify nobody and must never be reported. The venue states the fact; the host never guesses. |
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
bool IsIdentity = false) // this field names the PERSON at the venue (see below)
{
// Set when this field's empty value comes from a preset picked in another Choice field.
public ProviderFieldPresetHint? PresetHint { get; init; }
}
IsIdentity is not a UI flag. Set it only on a login-type field that identifies the
PERSON at the venue: a broker-assigned user name or account login. Such a field is the only one
that ever leaves the machine, and then only as a hash, for trial-abuse detection. Never set it on a
password or any other secret, and never on a product-owned demo login that every user shares: that
identifies nobody and would make unrelated users look like one person.
Preset-driven defaults
Some venues offer a Choice field whose options each bring their own defaults: a server
preset that comes with its endpoint and model name, say. The dependent fields stay optional, and the
venue resolves an empty value to the picked preset's default when it connects. A
ProviderFieldPresetHint on the dependent field lets the form show the user which value
that will be.
ProviderFieldPresetHint.csnamespace SabrTrader.Pipeline.Providers;
public sealed record ProviderFieldPresetHint(
string ChoiceFieldKey, // Key of the Choice field that picks the preset
IReadOnlyDictionary<string, string> HintByChoice); // option -> value an empty field takes
When the user picks another option in ChoiceFieldKey, the host empties the dependent
field, so the new preset's default applies, and shows HintByChoice's entry for that
option greyed in the empty input. An option with no entry (a "Custom" preset) shows no hint, and the
user has to type the value. The hint is display only: your session still does the resolving.
Descriptor.csprivate static readonly IReadOnlyDictionary<string, string> EndpointByPreset =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["Production"] = "https://api.example.com",
["Sandbox"] = "https://sandbox.example.com",
// "Custom" has no entry: the user must enter an endpoint.
};
SettingsSchema:
[
new ProviderCredentialField(FieldPreset, "Server", ProviderCredentialKind.Choice,
Choices: ["Production", "Sandbox", "Custom"], DefaultValue: "Production"),
new ProviderCredentialField(FieldEndpoint, "Endpoint", ProviderCredentialKind.Url, Required: false,
HelpText: "Leave empty to use the server's own endpoint, shown greyed. Custom needs one.")
{
PresetHint = new ProviderFieldPresetHint(FieldPreset, EndpointByPreset),
},
]
HintByChoice from
the same preset table the session uses to fill an empty field, so the greyed value and the value the
venue actually connects with are always the same.Byte-compatible storage names
StorageName separates the persisted JSON property from the field key, so a venue can
keep a settings shape its saved profiles already use. 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. Dotted paths address nested objects.
| StorageName | Persisted as |
|---|---|
null (default) |
Under the field's Key. Secrets as a host-encrypted blob, plain values verbatim. |
"ProtectedApiKeyBase64" |
A flat property name. |
"PlainValues.Environment" |
Nested under a PlainValues object. |
"GatewaySecretsBase64.refresh_token" |
Nested under an encrypted-secrets object, a common home 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. The
host consults it 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 grammars: CME-style futures (month codes, digit-bearing roots, year disambiguation),
US equity tickers, separated FX pairs behind the ISO-4217 whitelist, and a crypto grammar
(Crypto is nullable; check it before use). 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);
}
}
Two shared primitives sit below the grammars, for the mapping code that does not parse free-typed input:
FuturesContractCode & InstrumentCanonicalKeyusing SabrTrader.Pipeline.Venues;
// The platform's ONE futures contract-code grammar: ROOT + monthCode + year.
string months = FuturesContractCode.MonthCodes; // "FGHJKMNQUVXZ" (Jan..Dec)
char code = FuturesContractCode.MonthCode(9); // 'U'
FuturesContractCode.TryMonthFromCode('U', out int m); // m == 9
bool dated = FuturesContractCode.IsDatedContract("MNQU6"); // true
string? root = FuturesContractCode.RootOf("MNQU6"); // "MNQ"; null when NOT a dated contract
// The provider-neutral identity carried on OrderRequest.InstrumentKey / Position.InstrumentKey.
string key = InstrumentCanonicalKey.Format(VenueAssetClass.Future, "ES", 2026, 6); // "future:ES:2026-06"
InstrumentCanonicalKey.TryParse("stock:AAPL", out VenueSymbol symbol);
RootOf never mangles a bare root, an equity or an
index into a prefix of itself; a caller that wants to fall back to the original symbol does so
explicitly. The year is 1, 2 or 4 digits (the spellings exchanges and vendors actually use), and the root
may itself end in a digit (SR3, M2K) because the month code is found by
position, not by scanning for the last letter. Resolving those year digits to a calendar year is
the host parser's job, because it needs a reference date and a disambiguation policy.
A complete descriptor
Acme Data: one credential, one choice field, an explicit storage name, and honest capabilities.
AcmeVenuePlugin.cspublic sealed class AcmeVenuePlugin : IVenuePlugin
{
public IReadOnlyList<IVenueDescriptor> Venues { get; } = new IVenueDescriptor[]
{
new AcmeVenueDescriptor(),
};
}
internal sealed class AcmeVenueDescriptor : IVenueDescriptor
{
// Saved profiles key on TypeId: never change it after release.
internal const string TypeId = "Acme";
internal const string FieldApiKey = "ApiKey";
internal const string FieldFeed = "Feed";
public VenueManifest Manifest { get; } = new(
TypeId: TypeId,
DisplayName: "Acme Data",
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 Acme Data API key. Leave blank when editing to keep " +
"the saved key.",
// The key persists 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 AcmeVenueSession(context);
}