Skip to main content

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:

  • getState returns every required collection, including empty arrays.
  • placeOrder returns a confirmed acknowledgement or rejection.
  • Without a host risk controller, trading.getOperationSupport() declares getState and placeOrder. A configured risk boundary must supply its placeOrder request factory or placement support is intentionally absent. The map also contains controller-owned lifecycle, read, and documented fallback operations. Adapter-backed financial operations such as cancelOrder remain 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.

broker.contract.test.ts
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: placeOrder resolves { 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, or 5xx is 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 subscribe and connect(host), publish that change through exactly one of them.
  • Account switch: call setActiveAccount with 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

OperationResolution meansWhat still confirms broker truth
placeOrderAccepted or rejected acknowledgementAn orders or state update
modifyOrder, modifyPositionThe adapter returned the updated recordThe following authoritative state
cancelOrder, cancelOrdersThe adapter confirmed cancellation; the controller applies focused cancellation eventsThe following authoritative orders window
Full position closeThe adapter confirmed the close; the controller removes the id locallyThe following authoritative positions window
Partial position close, reversePositionThe adapter completed, then the controller fetched and applied getState()That applied snapshot and later broker events
cancelAllOrders, flattenPositionsThe 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.

trading-observability.ts
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

SymptomLikely causeFirst check
UI action is absentOperation is not structurally supportedgetOperationSupport() and the exact adapter method
Accepted order has no row or lineOnly the acknowledgement arrivedorders / state events and a fresh getState()
One change appears twiceBoth push lanes delivered itEvent count and source for one correlation id
Removed row returnsA replacement list was treated as append/upsert, or an older snapshot wonComplete list contents and snapshot watermark
Duplicate row or markerBroker id changed for the same logical recordOrder, position, or execution ids across updates
Wrong account data appearsAccount switch and snapshot are not coherentactiveAccountId, account list, rules, and row account ids
Updates continue after unmountDisconnect or unsubscribe leakedOpen socket, timer, listener, and subscription counters
Bulk call rejects after visible changesBackend partially completed or response was lostCorrelation 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:

  1. Mount one real chart or trading widget and confirm the initial account and operation support.
  2. 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 placeOrder is still awaiting its result.
  3. Publish the working order, modify it under the same id, then remove it with a replacement list.
  4. Switch accounts and verify every visible rule, row, and action follows the new account.
  5. Force one reconnect race and one transport-unknown mutation outcome.
  6. 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