Test a broker integration
Test at the TradingControllerApi boundary with an isolated paper account. The
adapter is correct only when controller state continues to match authoritative
backend state through success, rejection, reconnect, account switching, and
teardown.
Start with the contract
First prove the smallest adapter:
getStatereturns every required collection, including empty arrays.placeOrderreturns a confirmed acknowledgement or rejection.- Without a host risk controller,
trading.getOperationSupport()declaresgetStateandplaceOrder. A configured risk boundary must supply itsplaceOrderrequest factory or placement support is intentionally absent. The map also contains controller-owned lifecycle, read, and documented fallback operations. Adapter-backed financial operations such ascancelOrderremain absent when their required adapter methods are absent.
Operation support is structural. Runtime features and entitlements may narrow what the current session offers, but they never make an absent adapter mutation callable.
The following suite is designed for a host test fixture. Implement
createMinimumHarness with your normal SDK authorization setup and an adapter
that exposes only getState, placeOrder, and one event lane. Its
replaceBackendState method must update the snapshot returned by getState,
while publish sends one TradingEvent through that lane.
import { describe, expect, it } from 'vitest';
import type {
SdkSymbolInfo,
TradingControllerApi,
TradingEvent,
TradingOrder,
TradingOrderContext,
TradingOrderDraft,
TradingState,
} from '@tradescript/pro/sdk';
interface BrokerContractHarness {
trading: TradingControllerApi;
replaceBackendState(state: TradingState): void;
publish(event: TradingEvent): void;
flush(): Promise<void>;
dispose(): Promise<void>;
}
type CreateMinimumHarness = () => Promise<BrokerContractHarness>;
const symbol: SdkSymbolInfo = {
ticker: 'AAPL',
exchange: 'NASDAQ',
type: 'stock',
};
const draft: TradingOrderDraft = {
accountId: 'paper-1',
symbol,
side: 'buy',
type: 'limit',
quantity: 10,
price: 185,
duration: { type: 'day' },
};
const context: TradingOrderContext = {
accountId: 'paper-1',
symbol,
lastPrice: 184.95,
};
export function runBrokerContractSuite(
createMinimumHarness: CreateMinimumHarness,
) {
describe('broker integration contract', () => {
it('declares only callable operations', async () => {
const harness = await createMinimumHarness();
try {
const support = harness.trading.getOperationSupport();
expect(support.getState).toBe(true);
expect(support.placeOrder).toBe(true);
expect(support.cancelOrder).not.toBe(true);
} finally {
await harness.dispose();
}
});
it('applies broker events without a snapshot reload', async () => {
const harness = await createMinimumHarness();
try {
const initial = await harness.trading.getState();
expect(initial.orders).toEqual([]);
const objects = harness.trading.createObjectProvider();
const orderObjects = () => objects.listObjects()
.filter(({ kind }) => kind === 'order');
const acknowledgement = await harness.trading.placeOrder(
draft,
context,
{ operationId: 'contract-order-1', origin: 'host' },
);
expect(acknowledgement.accepted).toBe(true);
expect(orderObjects()).toEqual([]);
const working: TradingOrder = {
...draft,
id: 'ord-1',
accountId: 'paper-1',
status: 'working',
filledQuantity: 0,
remainingQuantity: 10,
};
const workingState = { ...initial, orders: [working] };
harness.replaceBackendState(workingState);
harness.publish({ type: 'orders', orders: [working] });
await harness.flush();
expect(orderObjects().map(({ id }) => id)).toEqual(['order:ord-1']);
const repriced: TradingOrder = { ...working, price: 185.25 };
const repricedState = { ...workingState, orders: [repriced] };
harness.replaceBackendState(repricedState);
harness.publish({ type: 'orders', orders: [repriced] });
await harness.flush();
expect(orderObjects()).toEqual([
expect.objectContaining({
id: 'order:ord-1',
metadata: expect.objectContaining({ price: 185.25 }),
}),
]);
const emptyState = { ...repricedState, orders: [] };
harness.replaceBackendState(emptyState);
harness.publish({ type: 'orders', orders: [] });
await harness.flush();
expect(orderObjects()).toEqual([]);
} finally {
await harness.dispose();
}
});
});
}
The object provider reads the controller's cached state without calling the adapter again. The second test therefore proves three separate facts: acceptance is not an order row, one broker order keeps one stable id across updates, and an empty replacement list removes the row.
Exercise failure boundaries
Add focused cases for these outcomes:
- Confirmed rejection:
placeOrderresolves{ accepted: false, status: 'rejected', message }. No working order appears. - Unknown outcome: interrupt the response after the backend may have
submitted the order. The adapter rejects the promise, then reconciles state
before any retry. A timeout, connection loss,
401,429, or5xxis not a business rejection unless the backend proves the order was not submitted. - Reconnect race: pause event delivery, begin
getState, create a newer backend update, resolve the older snapshot, then resume after the snapshot's cursor or watermark. Assert that the newer fact wins. - Duplicate lanes: count controller updates for one backend change. If the
adapter implements both
subscribeandconnect(host), publish that change through exactly one of them. - Account switch: call
setActiveAccountwith an exact account id, then assert that the next snapshot contains that id, the matching account, and the correct account-scoped rules and rows. - Teardown: after unsubscribe,
disconnect, and owner destruction, publish another backend update. No controller subscriber should run, and test counters for sockets, timers, listeners, and server subscriptions should all be zero.
TradingEvent has no global revision. A test that only waits longer cannot
prove reconnect ordering; control the snapshot and event boundary explicitly.
Assert each mutation's completion contract
| Operation | Resolution means | What still confirms broker truth |
|---|---|---|
placeOrder | Accepted or rejected acknowledgement | An orders or state update |
modifyOrder, modifyPosition | The adapter returned the updated record | The following authoritative state |
cancelOrder, cancelOrders | The adapter confirmed cancellation; the controller applies focused cancellation events | The following authoritative orders window |
| Full position close | The adapter confirmed the close; the controller removes the id locally | The following authoritative positions window |
Partial position close, reversePosition | The adapter completed, then the controller fetched and applied getState() | That applied snapshot and later broker events |
cancelAllOrders, flattenPositions | The broker-native atomic operation returned a count, then the controller fetched and applied getState() | That applied snapshot and later broker events |
Do not implement cancelAllOrders or flattenPositions as a client-side loop.
If a non-atomic batch partially succeeds before an error, the public mutation
has no typed partial-success result. Reject it as an unknown outcome, publish or
reload the authoritative rows, and correlate the backend's succeeded and failed
ids in diagnostics. Never resolve the whole batch as successful merely because
one item completed.
Make failures observable
When connect(host) is implemented, use host.log for adapter and broker
diagnostics. The controller adds SDK logs and exposes the combined buffer through
trading.getLogs(); trading.clearLogs() clears it and emits logs-cleared.
import type {
TradingFinancialMutationOptions,
TradingHost,
TradingOrderDraft,
TradingPlaceOrderResult,
} from '@tradescript/pro/sdk';
export function logOrderAcknowledgement(
host: TradingHost,
draft: TradingOrderDraft,
result: TradingPlaceOrderResult,
correlationId: string,
mutation?: TradingFinancialMutationOptions,
) {
host.log({
level: result.accepted ? 'info' : 'warning',
source: 'adapter',
message: result.accepted ? 'Order acknowledged' : 'Order rejected',
metadata: {
correlationId,
operationId: mutation?.operationId,
origin: mutation?.origin,
accountId: draft.accountId,
canonicalSymbol: draft.symbol.canonicalSymbol,
brokerOrderId: result.order?.id,
outcome: result.status,
},
});
}
Log the supplied operationId, a backend correlation id, exact account and
broker record ids, event type, backend cursor or revision, lifecycle stage, and
outcome. Redact credentials, cookies, authorization headers, session tokens,
personal data, and raw account or order payloads. Do not reconstruct identity
from display labels for logging.
Clear logs at the start of a focused test, then include the relevant entries in the failure output:
trading.clearLogs();
// Run one contract case.
expect(trading.getLogs()).toEqual(expect.arrayContaining([
expect.objectContaining({ source: 'adapter' }),
]));
Troubleshoot by symptom
| Symptom | Likely cause | First check |
|---|---|---|
| UI action is absent | Operation is not structurally supported | getOperationSupport() and the exact adapter method |
| Accepted order has no row or line | Only the acknowledgement arrived | orders / state events and a fresh getState() |
| One change appears twice | Both push lanes delivered it | Event count and source for one correlation id |
| Removed row returns | A replacement list was treated as append/upsert, or an older snapshot won | Complete list contents and snapshot watermark |
| Duplicate row or marker | Broker id changed for the same logical record | Order, position, or execution ids across updates |
| Wrong account data appears | Account switch and snapshot are not coherent | activeAccountId, account list, rules, and row account ids |
| Updates continue after unmount | Disconnect or unsubscribe leaked | Open socket, timer, listener, and subscription counters |
| Bulk call rejects after visible changes | Backend partially completed or response was lost | Correlation id, per-record state, then scoped reconciliation |
Run a browser smoke test
Use an isolated account with executionEnvironment: 'paper'. This tests your
broker adapter against non-live execution; it does not simulate market data.
Mount the surface with the real MarketDataFeed integration you intend to ship
so quotes, symbol resolution, and order context still come from your backend.
Then run the smoke test:
- Mount one real chart or trading widget and confirm the initial account and operation support.
- Submit a deterministic order. Verify both acknowledgement feedback and the
authoritative row or line. Do not require their relative order unless the
adapter explicitly guarantees it; a broker event can arrive while
placeOrderis still awaiting its result. - Publish the working order, modify it under the same id, then remove it with a replacement list.
- Switch accounts and verify every visible rule, row, and action follows the new account.
- Force one reconnect race and one transport-unknown mutation outcome.
- Unmount the owner and confirm no later event changes the UI or controller logs.
Keep this smoke test out of live accounts. Capture controller logs, backend correlation evidence, and browser-console errors when it fails.
Next steps
- Order Ticket — mount the first entry surface on the verified controller.
- Broker adapter — lifecycle, ownership, and structural operation support.
- Accounts, capabilities, and symbol rules — account switching and exact route gates.
- State and events — replacement semantics and reconnect ordering.
- Orders — acknowledgements, order identity, mutations, and retries.
- Positions — position actions, P&L, and protection.
- Runtime testing — exercise a built SDK and browser integration.
- Trading API reference — exact public contracts.