The plugin & manifest

Venue plugins

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.

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; }
}
Non-negotiable for discovery. Public, concrete, parameterless constructor. A type that misses any of the three is skipped at scan time, and its venues stay out of the Connections dialog. A unit test that constructs your plugin with 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.
Write the HelpText. It is the only place a user learns where to get the credential, what a blank value means when editing an existing profile, and which plan tier the field needs. The generated form has no other prose.

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),
    },
]
Read the hints from the table your session resolves against. Build 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.
Pin it with a test. When a venue keeps an existing settings shape, pin its settings JSON byte-identical against a captured real profile. A field that changes storage shape breaks the profile on its first edit, and the user loses the saved credentials.

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);
Null means "not a dated contract". 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);
}
Declare honestly. The host's registrars act on the manifest before any session exists, so a declared capability whose port is absent on a connected session leaves an empty registration, and the host logs a port that is wider than the manifest as a venue bug. Declare exactly what the venue serves.