Skip to main content

State and events

TradingState is the controller's current broker snapshot. TradingEvent variants can replace or patch that snapshot, update controller diagnostics, or only notify subscribers. Your adapter owns the boundary between backend ordering and these public contracts.

Who owns what

FactOwner
Accounts, active account, balances, and capabilitiesBroker backend and adapter
Orders and historyBroker backend and adapter
ExecutionsBroker backend and adapter
Net and individual positionsBroker backend and adapter
Current normalized in-memory snapshotTradingControllerApi
Lines, markers, tables, messages, and transient feedbackChart and trading widgets

The SDK does not derive positions from fills, executions from order counters, or account capabilities from names.

Return one complete initial state

Return every required collection from getState, including empty arrays.

state.ts
import type { SdkSymbolInfo, TradingState } from '@tradescript/pro/sdk';

declare const resolvedSymbol: SdkSymbolInfo;

export const state: TradingState = {
connectionStatus: 'connected',
activeAccountId: 'acct-1',
accounts: [{
id: 'acct-1',
name: 'Main account',
currency: 'USD',
isActive: true,
}],
symbolInfos: [{
symbol: resolvedSymbol,
accountId: 'acct-1',
minQuantity: 1,
quantityStep: 1,
priceStep: 0.01,
}],
orders: [],
positions: [],
executions: [],
};

activeAccountId must identify an account in the same snapshot. Every order, position, and execution must belong to the reported account and exact SdkSymbolInfo. Its id must stay stable and be globally unique within the controller; net and individual positions share one id namespace. Prefix native account-scoped ids before publishing them.

Know how each event is reduced

Use the exported TradingEvent union directly. Do not maintain a local copy of its shapes.

Event familyController effect
connection-statusPatches state.connectionStatus.
stateReplaces the complete snapshot.
accountsReplaces the account list and assigns activeAccountId exactly; omitting the event field clears the cached selection.
features, symbol-infoReplaces the declared feature or symbol-info slice.
orders, orders-history, positions, individual-positions, individual-position-modified, executions, plural balance/transaction eventsReplaces the complete named collection. Rows omitted from the event are removed.
position-plPatches P&L on matching net and individual positions.
crypto-balance, cash-transactionUpserts one item by its stable asset or transaction id.
messageAppends one message when history is enabled.
order-modified, order-expired, position-modifiedUpserts the returned record; expiry also updates order history.
order-cancelledMarks the cached order cancelled and upserts it into order history; the reducer supplies the current time when updatedAt is absent.
position-closedRemoves the id from both net and individual position collections.
log, logs-clearedUpdates the controller's diagnostic log buffer, not TradingState.
Acknowledgement, notification, settings, leverage, protection-request, and draft eventsDelivers feedback or host state; it is not authoritative replacement order/position/execution state.
import type {
TradingEvent,
TradingOrder,
TradingPosition,
} from '@tradescript/pro/sdk';

declare const currentOrders: TradingOrder[];
declare const currentPositions: TradingPosition[];

export const ordersEvent = {
type: 'orders',
orders: currentOrders,
} satisfies TradingEvent;

export const positionsEvent = {
type: 'positions',
positions: currentPositions,
} satisfies TradingEvent;

An orders event replaces state.orders; it is not an append or upsert. The same replacement rule applies to positions and executions. Publish every row in the chosen collection window once, with one stable backend id.

A fill can update an order, add an execution, and open or resize a position. Publish one complete state event when subscribers must observe the transition atomically.

import type {
TradingExecution,
TradingHost,
TradingOrder,
TradingPosition,
TradingState,
} from '@tradescript/pro/sdk';

declare const host: TradingHost;
declare const latestState: TradingState;
declare const latestOrders: TradingOrder[];
declare const latestExecutions: TradingExecution[];
declare const latestPositions: TradingPosition[];

host.emit({
type: 'state',
state: {
...latestState,
orders: latestOrders,
executions: latestExecutions,
positions: latestPositions,
},
});

Separate collection events are applied synchronously in arrival order. A subscriber can observe the intermediate state after each event.

Choose one push lane

Publish each backend change through either the TradingHost supplied to connect(host) or the callback registered by subscribe(callback). Do not send the same change through both.

Controller mutation methods also emit some result events themselves. For example, modifyOrder upserts its returned order and cancelOrder marks the order cancelled after the adapter promise resolves. Do not echo those same method results through the adapter stream merely to make the calling controller update. Continue publishing the backend's later authoritative replacement list or complete state so this controller and every other client converge.

See Orders and Positions for the exact completion behavior of each mutation.

Reconnect without going backwards

connect() does not call getState(), and the controller does not reconnect the adapter automatically. The host must connect and fetch the first snapshot. Every adapter getState() call must then pause delivery and release only events strictly newer than that snapshot's backend cursor.

The boundary applies beyond initial connection. The controller also calls getState() after cancelAllOrders, a partial position close, reversePosition, and flattenPositions. Concurrent state-refreshing mutations must be serialized so an older read cannot overwrite a newer event or snapshot.

The cursor below is adapter-private because TradingEvent has no global public revision.

ordered-broker.ts
import type {
TradingBrokerAdapter,
TradingControllerApi,
TradingEvent,
TradingHost,
TradingState,
} from '@tradescript/pro/sdk';

interface StreamEnvelope {
cursor: number;
event: TradingEvent;
}

interface SnapshotEnvelope {
cursor: number;
state: TradingState;
}

declare function openTradingStream(
onMessage: (message: StreamEnvelope) => void,
): () => void;
declare function loadTradingSnapshot(signal: AbortSignal): Promise<SnapshotEnvelope>;
declare const placeOrder: TradingBrokerAdapter['placeOrder'];
declare const trading: TradingControllerApi;

let host: TradingHost | undefined;
let stopStream: (() => void) | undefined;
let snapshotAbort: AbortController | undefined;
let resumeTimer: ReturnType<typeof setTimeout> | undefined;
let snapshotQueue: Promise<void> = Promise.resolve();
let generation = 0;
let snapshotWindow = 0;
let lastCursor = -1;
let live = false;
let buffered: StreamEnvelope[] = [];

function deliver(message: StreamEnvelope) {
if (message.cursor <= lastCursor) return;
lastCursor = message.cursor;
host?.emit(message.event);
}

function resumeAfterSnapshot(
cursor: number,
expectedGeneration: number,
expectedWindow: number,
) {
if (generation !== expectedGeneration || snapshotWindow !== expectedWindow) return;
lastCursor = cursor;
for (const message of buffered.sort((a, b) => a.cursor - b.cursor)) {
deliver(message);
}
buffered = [];
live = true;
}

async function serializeSnapshot<T>(load: () => Promise<T>): Promise<T> {
const previous = snapshotQueue;
let release = () => {};
snapshotQueue = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await load();
} finally {
release();
}
}

export const broker = {
executionEnvironment: 'live',

async connect(nextHost) {
const currentGeneration = ++generation;
host = nextHost;
live = false;
buffered = [];
snapshotWindow = 0;
lastCursor = -1;
if (resumeTimer !== undefined) clearTimeout(resumeTimer);
resumeTimer = undefined;
snapshotAbort?.abort();
snapshotAbort = undefined;
stopStream?.();
stopStream = openTradingStream((message) => {
if (currentGeneration !== generation) return;
if (!live) buffered.push(message);
else deliver(message);
});
return { status: 'connected', connectionType: 'streaming' };
},

async getState() {
const invocationGeneration = generation;
return serializeSnapshot(async () => {
if (invocationGeneration !== generation) {
throw new Error('Snapshot request belongs to a stale connection.');
}
if (host === undefined) throw new Error('Connect before loading streaming state.');
const currentGeneration = invocationGeneration;
const currentWindow = ++snapshotWindow;
live = false;
if (resumeTimer !== undefined) clearTimeout(resumeTimer);
resumeTimer = undefined;

const request = new AbortController();
snapshotAbort = request;
const snapshot = await loadTradingSnapshot(request.signal);
if (currentGeneration !== generation || currentWindow !== snapshotWindow) {
throw new Error('Stale snapshot generation.');
}
if (snapshot.cursor < lastCursor) {
throw new Error('Snapshot is older than an event already delivered.');
}
snapshotAbort = undefined;

// The controller applies the returned snapshot in its Promise continuation.
// Resume buffered events in the following task, after that apply completes.
resumeTimer = setTimeout(() => {
resumeTimer = undefined;
resumeAfterSnapshot(snapshot.cursor, currentGeneration, currentWindow);
}, 0);
return snapshot.state;
});
},

async disconnect() {
generation += 1;
snapshotWindow += 1;
live = false;
buffered = [];
if (resumeTimer !== undefined) clearTimeout(resumeTimer);
resumeTimer = undefined;
snapshotAbort?.abort();
snapshotAbort = undefined;
stopStream?.();
stopStream = undefined;
host = undefined;
},

placeOrder,
} satisfies TradingBrokerAdapter;

export async function connectAndLoad() {
try {
await trading.connect();
await trading.getState();
} catch (cause) {
await trading.disconnect().catch(() => undefined);
throw cause;
}
}

Use a strictly increasing cursor per envelope, a bounded buffer, and fail closed if it overflows or a snapshot arrives behind lastCursor. On authentication expiry, stop the current generation, refresh through the host's normal authentication flow, apply bounded backoff, and repeat the same snapshot boundary. The snapshot loader must honor its AbortSignal so a new generation does not wait behind abandoned work. A stale socket or snapshot request must never publish after its generation ends.

Verify the contract

  • Replaying the same full snapshot creates no duplicate rows.
  • One replacement event removes rows omitted from its list.
  • One fill becomes visible as a coherent order, execution, and position change.
  • A forced event-during-snapshot race never lets stale state win.
  • One backend change reaches subscribers through one lane.
  • Adapter disconnect() stops later backend pushes; destroying the owner then releases the controller subscription and listeners.

The event union is in the Trading API reference.

Next steps

Continue with Accounts, capabilities, and symbol rules.