Example: TradeZero
This is a worked example, not a shipped adapter. It covers the case that forces the largest architectural change: a broker that executes orders but publishes no market data at all, which splits the backend in two.
TradeZero's developer API states plainly that it does not provide quotes, charts, or historical prices. Everything the chart draws must come from somewhere else. Verify current endpoints, limits, and terms against TradeZero's own documentation before building.
The split backend
Two upstreams, two identity spaces. The instrument a user charts and the
instrument you route an order for are the same asset described by two different
systems, so hold one map between them. Put the vendor's identity in
SdkSymbolInfo.ticker and the broker's routing identity in brokerSymbol — the
SDK keeps them distinct precisely for this case, and never derives one from the
other.
The left-hand path is a vendor integration like any other — see Market data vendors. Everything below concerns the right-hand one.
Authorization
TradeZero uses API keys rather than a desktop session — no GUI process to keep running, which makes it operationally simpler than the IBKR desktop path.
Setup is account-side before it is code-side: enable the API Trading add-on in the Client Portal, sign the API Trading Agreement, then generate an API Key ID and Secret Key. The secret is shown once.
Paper and live use the same base URL. Which environment you are in is determined
entirely by which key pair you present — so the key pair is the environment.
Store them separately, never in the same secret, and declare the result to the
SDK through executionEnvironment rather than inferring it from an account
name.
Requests are rate limited, with a documented default of 200 requests per minute per account. That budget is per account and shared by every open browser tab — the same argument for centralizing calls in the gateway that applies to every other upstream.
One licensing constraint shapes who can use this pattern: access is for personal use, not for building a platform served to other users. A desk charting its own accounts is a different thing from a product offered to clients, and that difference is contractual, not technical.
REST surface
| Purpose | Route |
|---|---|
| Account state and balances | /account |
| Open positions | /positions |
| Realized and unrealized P&L | /pnl |
| Historical orders | /orders/start-date/{date} |
| Submit an order | POST /v-1/api/accounts/{account-id}/order |
| Cancel an order | DELETE /v-1/api/accounts/{account-id}/orders/{client-order-id} |
Cancellation is keyed on your client order id, so generate one per order,
persist it, and keep it correlated with TradeZero's own id — the same
ClOrdID-versus-OrderID discipline that FIX
requires.
Routes are explicit rather than implied. Live accounts route through SMART, CTDL, SMARTO, SMARTM, and ARCA plus direct market access; paper accounts use the simulated PAPER and PAPERM routes. Surface the route as an order-ticket field rather than hard-coding one, since route choice is a trading decision.
Order coverage is market, limit, and stop for equities, plus single-leg and
multi-leg options strategies. Report that honestly through
getTradingSymbolInfo so the order ticket only offers order types the broker
will accept — see Order ticket
configuration.
WebSocket streams
Two streams carry live state: /stream/pnl for account value, leverage, and
per-position P&L on price ticks, and /stream/portfolio for order state changes
and position updates from fills and cancellations.
The handshake is server-initiated, which is unusual enough to get wrong the first time.
Three behaviors to build for:
- Update messages carry only changed fields. Merge them into state you hold; do not treat an update as a complete record.
- The server sends no keepalive ping. Detect a dead socket yourself, and reconnect with exponential backoff in the 1–30 second range, resetting the counter after a successful
CONNECTED. FAILED_AUTHis terminal. Retrying a rejected credential is how you get an account locked, not how you recover.
Mapping the streams onto trading events:
| Stream message | SDK event |
|---|---|
Portfolio, subscription: "Order" | { type: 'orders', orders } |
Portfolio, subscription: "Position" | { type: 'positions', positions } |
P&L, target: "position" | { type: 'position-pl', positionId, unrealizedPnl } |
P&L, target: "aggCalcs" | Account balances in the next state snapshot |
Any FAILED_AUTH or socket loss | { type: 'connection-status', status: 'disconnected' } |
The adapter
import type {
TradingBrokerAdapter,
TradingEvent,
TradingPosition,
TradingState,
} from '@tradescript/pro/sdk';
const listeners = new Set<(event: TradingEvent) => void>();
function emit(event: TradingEvent): void {
for (const listener of listeners) listener(event);
}
/** Called by your backend bridge when a portfolio-stream position update arrives. */
export function onPositionUpdate(positions: TradingPosition[]): void {
emit({ type: 'positions', positions });
}
/** Called when the P&L stream reports a per-position change. */
export function onPositionPnl(positionId: string, unrealizedPnl: number): void {
emit({ type: 'position-pl', positionId, unrealizedPnl });
}
export const broker: TradingBrokerAdapter = {
executionEnvironment: 'paper',
async getState(): Promise<TradingState> {
const response = await fetch('/api/tz/state');
if (!response.ok) throw new Error(`State unavailable: ${response.status}`);
return response.json() as Promise<TradingState>;
},
subscribe(callback) {
listeners.add(callback);
return () => listeners.delete(callback);
},
async placeOrder(draft, context) {
const response = await fetch('/api/tz/orders', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
accountId: context.accountId,
symbol: draft.symbol.brokerSymbol ?? draft.symbol.ticker,
side: draft.side,
type: draft.type,
quantity: draft.quantity,
price: draft.price,
stopPrice: draft.stopPrice,
}),
});
if (!response.ok) {
return { accepted: false, status: 'rejected', message: await response.text() };
}
return { accepted: true, status: 'submitted' };
},
};
Your backend holds the key and secret and does the routing; the browser holds neither. A rejection returns its message so the trader sees why, rather than an order that silently never appears.
Checklist
- A market data source is chosen and wired before any chart work starts.
- Vendor identity and broker routing identity are mapped explicitly, not derived from each other.
- Keys live server-side; paper and live key pairs are stored separately.
executionEnvironmentis declared from the key pair in use.- The 200-requests-per-minute budget is enforced centrally with
429backoff. - Stream updates are merged as partial records; socket liveness is detected without relying on a server ping.
FAILED_AUTHstops retrying and raises an operational alert.- Route selection is exposed as a trading decision, and paper accounts use the simulated routes.
Next steps
- Market data vendors — filling the other half of this architecture.
- Broker integration — the complete production path.
- Orders — order state transitions the events above must respect.