Contract tests & capabilities
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.
On this page
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.
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);
}
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,
});
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. |
StartAsync (subscribe first, then start) and a scripted reconnect.Shipping the broker
- One DLL per broker, publishing the FULL transitive vendor closure from its
CopyLocalLockFileAssembliesbin, 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.
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.