Contract tests & capabilities

Broker venues

Contract tests & capabilities

Declaring capability across both layers, publishing the session ports, keeping saved profiles byte-compatible, and the behavioural promises every trading port must keep.

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 expected. A broker whose reporting credentials are absent 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.
VenueRealizedPnl The venue reports TODAY's realized PnL on its account snapshots. Without it, Account.RealizedPnL must stay null.
EnforcesOcoGroups Your provider enforces an OcoGroupId it receives on individual PlaceAsync calls (at least ProtectiveExits groups) in its own ClientSideOcoCoordinator: a fill of one member takes the others down while the application runs, whether or not a window still watches the trade. Distinct from NativeOco, where the venue holds the group and it survives a crash.
ProtectionIsPositionLevel The venue holds protection ON THE POSITION (one stop and one target per position or trade ticket, set as its levels) rather than as standalone orders, so a template with several stop rows cannot be expressed on it.
DurableOrderIds The venue's order ids survive a reconnect and an application restart, so an order placed before can be recognised again by its broker id afterwards. A connection-scoped id does not qualify.
BrokerIdChangesOnModify A modify gives the order a new broker order id (a cancel-replace venue). The platform OrderId stays, so a report under the previous broker id still belongs to the same order.
SupportsAtm Your provider keeps every one of the ATM promises, proven by tests, and your venue meets at least one of: position rows that lag a fill by no more than the position-removal debounce, statements of all account activity including orders placed elsewhere, or reduce-only protective legs. Declared only when proven, never assumed.
RestatesOrdersAcrossSessions After an application restart, a reconnect and a provider rebuild, your provider states the working orders an earlier session placed (it replays the account's open orders, whoever placed them and when), so a scan of the working orders you state finds protection that still rests from before. Without it the ATM refuses to adopt, or to re-protect a trade started in an earlier session, unless the trader confirms nothing rests. Declared only when proven, never assumed.

Two provider properties declare commission facts rather than features: ReportsCommission (every fill carries the venue's own fee) and RealizedPnLIncludesCommissions (whether Account.RealizedPnL is net of fees; null until proven). Per fill, stamp Fill.CommissionIsAuthoritative whenever the fee is the venue's own. See Venue-reported fees.

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. Pin the synthesised capability with a test against your venue double; that test is what 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.
IOrderHistorySource None (probed on every connected session) The orders an account placed over a window, read-only. See Order history.
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 that report financing in real time on their fills or positions leave the port out, so the same figure is never counted twice.

Settings & credential rotation

A broker's profile JSON often nests: a UsePaper boolean at the root, a PlainValues object, a ProtectedSecretsBase64 object, and a GatewaySecretsBase64 object for vendor tokens. The schema's dotted StorageName addresses each of them, so a saved shape stays byte-for-byte the same.

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 encrypted object, 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. A shape mismatch shows up late: the profile loads fine until the user edits it once.

Testing a trading port

The platform's order path relies on a handful of behavioural promises from every ITradingProvider. Test each one against your provider running over a scripted double of your venue (the real provider code, only the venue faked), so every state change reaches the provider the way it does in production. Each promise below exists because breaking it has cost a trader money.

Promise What breaks without it
CancelAsync of an unknown order returns false and never throws 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.
CancelAsync of a terminal order returns false The ATM races its cancel against a fill and treats false as "too late".
Cancelling a working order cancels it exactly once, and it never flips to Filled afterwards Terminal must be terminal: a re-opened order is an unmanaged position.
IOcoLegGateway.CancelLegAsync of a known-terminal order returns false AND TryGetOrder still finds it, terminal False means "retry" to ClientSideOcoCoordinator, and TryGetOrder is its only short-circuit. False plus an untracked order is an unbounded retry loop.
Account.RealizedPnL is null when VenueRealizedPnl is not declared A fabricated 0m silently disables every PnL-based risk rule.
A fill's commission is the venue's own (stamped CommissionIsAuthoritative) or an unstamped zero The live commission step prices every unstamped fill, so a real venue fee sent without the stamp is replaced by the trader's configured estimate.

The ATM promises (P1 to P7)

The ATM decides on what your provider states. Each promise names the wrong fact it would otherwise act on. Declare SupportsAtm only when your provider keeps all seven, proven by tests.

Promise What breaks without it
P1: order statements arrive in venue order. A fill total never goes down, and a terminal order never comes back. A fill total that goes down sizes protection below the position; a terminal order that comes back gets protected although it is done.
P2: the record is published before its event. OpenOrders already shows what an event says when the event is raised. A consumer that reads your OpenOrders on an event sees the state before it and acts on an order the event already changed.
P3: ClientTag survives every state change. The ATM finds its legs by tag; a stop whose tag is dropped on its fill cannot be attributed, and its target is never taken down.
P4: a triggered stop fills under its own OrderId. The fill of a stop that became another order server-side lands on an id nobody knows: the closed position still looks protected and the target keeps working.
P5: a position the venue closed is stated flat at least once. A position closed at the venue stays open in every consumer.
P6: position statements never regress. An older frame never resurrects a position. An older frame resurrects a position that is gone.
P7: BooksSyncedEvent is raised exactly once per connect (and once more after a reconnect), after the connect's ConnectionStateEvent, carrying exactly the non-flat positions stated before it. Never raised: no absence is ever proof. Raised early, or with a book that is not yours: a held position reads flat.
Test with partial fills and a resting stop. P1 and P3 need a resting order of at least 2 units so it can be partly filled; P4 needs a stop your venue double leaves resting until the test triggers it; P7 needs the events recorded from before StartAsync (subscribe first, then start) and a scripted reconnect.

Shipping the broker

  • One DLL per broker, publishing the FULL transitive vendor closure from its CopyLocalLockFileAssemblies bin, not a hand-written dependency list.
  • Native assets (runtimes\) and content files ship with it. Vendor DLLs next to the plugin resolve through the plugin load context.
  • Run the plugin checks from Testing your plugin and the trading promises above before each release.
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. A mode qualifier alone collides for two same-mode logins; the host keys on the profile, so give each session its own vendor client.