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 silently skipped at scan time and the venue never appears in the Connections dialog — which is exactly what the conformance kit's first check exists to catch.

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
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.

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.
Pin it with a test. A venue converting from an older shape must pin its settings JSON byte-identical against a captured REAL profile before the cutover. A field that silently changes storage shape bricks the profile on its first edit — that is a customer-visible data loss, not a cosmetic drift.

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);
}
Declare honestly. The conformance kit punishes both over- and under-claiming: a declared capability whose port is absent on a connected session fails, and so does a port that is wider than the manifest. Capabilities are a contract with the host's registrars, not a marketing surface.