Contract kit & capabilities

Broker venues

Contract kit & capabilities

Declaring capability across both layers, publishing the session ports, keeping saved profiles byte-compatible, and passing the two merge gates every trading venue is held to.

Two capability layers

A broker declares capability twice, at two different times, and the two are not redundant.

Layer Where Read when Meaning
VenueCapabilities The manifest Before any session exists INTENT — what this venue can ever do. Drives dialog grouping and which host registrars schedule work.
TradingProviderCapabilities The trading port Per connected session FACT — what this session can do right now. May be narrower (entitlement, account tier, partial connect); never wider.
ProviderCapabilities The data port Per connected session FACT for the data plane, same rule.

Narrowing at runtime is not a bug — it is the point. A broker whose Flex credentials are absent genuinely cannot serve trade history this session, and expresses that by not publishing the port. The host logs a wider-than-declared port as a venue bug at bring-up.

Declaring honestly

Flags are deliberately fine-grained so a partial implementation can declare exactly what it supports. A read-only "show me my live positions" phase declares AccountDiscovery | PositionStream and nothing else; the UI greys out the rest instead of throwing at click time.

Flag Only when
Brackets A BracketSpec can be attached to an entry order.
NativeOco The venue cancels the surviving leg — so the link survives an app crash. Excluded from Full: it is an opt-in venue property, never implied.
ShortSelling The account can sell to open and reverse. A cash equity or spot-crypto account omits it.
AssetBalances The account holds many assets rather than one cash figure.
MultiLegOrders · ExerciseOptions The venue has the atomic endpoint. Both excluded from Full.
Leverage Leveraged derivatives (crypto perps). A COARSE UI gate only — actual limits are per-instrument metadata, never encoded here.
VenueRealizedPnl The venue reports TODAY's realized PnL on its account snapshots. Without it, Account.RealizedPnL must stay null.

Synthesised capabilities

A capability your plugin implements by synthesis still counts — declare it. Modify via cancel-replace is ModifyOrders. Flatten via cancel-all plus market-close is FlattenPositions. The contract kit then pins that the declared capability actually works, which is exactly the check that keeps a synthesis honest as the vendor API drifts.

Synthesised modify implies revision tracking. A cancel-replace produces two venue orders for one platform order, and the venue will stream the superseded one's cancel. Use OrderRevisionTracker — see the components table.

Session ports

Side channels are ordinary session ports, probed with AsPort<T>() and registered by the host's port registrar while the session is connected. Declare the matching manifest flag so the registrar looks for them.

Port Manifest flag Notes
IVenueTradeHistorySource TradeHistoryBackfill Supports(account) answers for accounts behind THIS login; GetFillsAsync(account, fromUtc, toUtc, ct) reports the window's fills as a VenueTradeHistoryPage. Read-only: the host owns the store write and the coverage bookkeeping. Set IsComplete false (or return VenueTradeHistoryPage.Partial) whenever you could not serve the whole window — a window reported complete is never requested again.
IVenueCashActivitySource CashActivity Posted financing / interest / dividends, for statement-model venues.
IOptionChainSource OptionChains Underlying search, chain fetch, live quote subscription. See Options.
IVenueCashActivitySource.cspublic sealed record VenueCashActivity(
    string AccountId,            // the VENUE's raw account id — the host recorder scopes it
    CashActivityKind Kind,
    decimal Amount,              // NORMATIVE sign: positive = collected, negative = paid
    string Currency,
    DateTime TimestampUtc,
    string VenueTransactionId);  // the dedup identity

public interface IVenueCashActivitySource
{
    Task<IReadOnlyList<VenueCashActivity>> GetCashActivityAsync(
        DateTime sinceUtc, CancellationToken ct = default);
}
Two rules on cash activity. The sign is normative, not venue-raw — normalise it, because two venues passing raw signs through would diverge the ledger. And a venue without a stable per-entry transaction id cannot expose this port at all: the recorder dedups on that id, and without it an overlapping re-fetch double-counts. Venues with realtime financing on their fills or positions also do not expose it, so the same figure is never double-sourced.

Settings & credential rotation

A broker's legacy profile JSON typically nests: a UsePaper boolean at the root, a PlainValues object, a ProtectedSecretsBase64 object, and a GatewaySecretsBase64 bag for vendor tokens. All of it rides the schema's dotted StorageName mechanism, so the shape survives the move byte-for-byte.

Broker settings schemaSettingsSchema: new[]
{
    new ProviderCredentialField("UsePaper", "Paper trading",
        ProviderCredentialKind.Toggle, Required: false, DefaultValue: "false"),

    new ProviderCredentialField("Environment", "Environment",
        ProviderCredentialKind.Choice,
        Choices: new[] { "Live", "Demo" },
        StorageName: "PlainValues.Environment"),

    new ProviderCredentialField("ClientId", "Client ID",
        ProviderCredentialKind.Plain,
        StorageName: "PlainValues.ClientId"),

    new ProviderCredentialField("ClientSecret", "Client secret",
        ProviderCredentialKind.Secret,
        StorageName: "ProtectedSecretsBase64.ClientSecret"),

    // Rotating OAuth tokens: written back through IVenueCredentialWriter and stored
    // in the legacy encrypted bag, so the next start does not re-prompt for login.
    new ProviderCredentialField("RefreshToken", "Refresh token",
        ProviderCredentialKind.Secret, Required: false, ReadOnly: true,
        StorageName: "GatewaySecretsBase64.refresh_token"),
}
Persisting a refresh// After every successful token refresh — not just the first.
context.Credentials.Persist(new Dictionary<string, string>
{
    ["AccessToken"]  = tokens.Access,
    ["RefreshToken"] = tokens.Refresh,
});
Pin the shape before you ship. Deserialize a captured REAL profile, run it through your descriptor's schema, re-serialize, and assert byte equality. Every OAuth broker that skipped this step discovered it the same way: the profile loads fine until the user edits it once.

The trading contract kit

TradingProviderContractKit (in SabrTrader.Pipeline.Venues.Tests) is the merge gate for any direct ITradingProvider implementation. You supply a harness — a connected provider over a scripted venue double — and the kit drives the incident-derived behavioural contracts through it.

ITradingProviderContractHarness.cspublic interface ITradingProviderContractHarness : IAsyncDisposable
{
    ITradingProvider Provider { get; }        // connected, under test
    AccountId Account { get; }                // must appear in Provider.Accounts

    OrderRequest CreateRestingOrderRequest(); // acked Working, never filled unprompted
    Task FillCompletelyAsync(Order order);    // script the venue to fill it
}

public sealed class MyBrokerContractTests : TradingProviderContractKit
{
    protected override async Task<ITradingProviderContractHarness> CreateConnectedHarnessAsync()
        => await MyBrokerHarness.StartAsync();
}
Check The incident behind it
Cancel of an unknown order is false, never a throw An emergency flatten sweep cancels ids it may no longer own; one throw aborts the sweep and leaves live orders on an account the trader believes is flat.
Cancel of a terminal order is false The ATM races its cancel against a fill and treats false as "too late".
Cancel of a working order cancels exactly once and never flips to Filled Terminal must be terminal — a re-opened order is an unmanaged position.
CancelLegAsync of a known-terminal order is false AND still tracked terminal The OCO dual contract (2026-08-05): false means "retry", and TryGetOrder is the coordinator's only short-circuit. False + untracked = an unbounded retry alarm.
RealizedPnL is null when VenueRealizedPnl is not declared A fabricated 0m silently disables every PnL-based risk rule.
Fills carry zero commission when ReportsCommission is false The live commission decorator enriches fills when it is false; a venue-reported figure under false double-charges.
Checks skip rather than fail when your provider genuinely lacks the surface being pinned — which is why a skip must always be a decision you can name. A kit self-test proves every check goes red against a seeded violator; a kit that cannot go red is not a gate.

The seam conformance kit

Every venue, trading or not, also subclasses VenuePluginConformanceKit — loader compatibility, manifest validity, verbatim legacy TypeIds, schema well-formedness, contracts-only references, offline lifecycle safety. See Instruments & discovery for the full check list.

MyBrokerConformanceTests.cspublic sealed class MyBrokerVenuePluginConformanceTests : VenuePluginConformanceKit
{
    protected override IVenuePlugin CreatePlugin() => new MyBrokerVenuePlugin();
    protected override IReadOnlyList<string> LegacyTypeIds => new[] { "mybroker" };
}

Shipping the broker

  • One DLL per broker, publishing the FULL transitive vendor closure from its CopyLocalLockFileAssemblies bin — never a hand-written dependency list.
  • Native assets (runtimes\) and content files ship with it. A vendor sidecar in the app root resolves through the plugin load context's last-chance hook.
  • Build with -p:SkipPluginDeploy=true when an app instance may hold the DLL.
  • Run all three suites before merging: your own tests, the conformance kit, and the trading contract kit.
Concurrent logins. A broker that supports two simultaneous profiles (prop-firm double logins, live + demo) sets MaxConcurrentSessions above 1 and gets an independent session per profile. Note that a mode qualifier alone collides for two same-mode logins — the host keys on the profile, so your session must not assume a single global vendor client.