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
| Fact | Owner |
|---|---|
| Accounts, active account, balances, and capabilities | Broker backend and adapter |
| Orders and history | Broker backend and adapter |
| Executions | Broker backend and adapter |
| Net and individual positions | Broker backend and adapter |
| Current normalized in-memory snapshot | TradingControllerApi |
| Lines, markers, tables, messages, and transient feedback | Chart 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.
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 family | Controller effect |
|---|---|
connection-status | Patches state.connectionStatus. |
state | Replaces the complete snapshot. |
accounts | Replaces the account list and assigns activeAccountId exactly; omitting the event field clears the cached selection. |
features, symbol-info | Replaces the declared feature or symbol-info slice. |
orders, orders-history, positions, individual-positions, individual-position-modified, executions, plural balance/transaction events | Replaces the complete named collection. Rows omitted from the event are removed. |
position-pl | Patches P&L on matching net and individual positions. |
crypto-balance, cash-transaction | Upserts one item by its stable asset or transaction id. |
message | Appends one message when history is enabled. |
order-modified, order-expired, position-modified | Upserts the returned record; expiry also updates order history. |
order-cancelled | Marks the cached order cancelled and upserts it into order history; the reducer supplies the current time when updatedAt is absent. |
position-closed | Removes the id from both net and individual position collections. |
log, logs-cleared | Updates the controller's diagnostic log buffer, not TradingState. |
| Acknowledgement, notification, settings, leverage, protection-request, and draft events | Delivers 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.
Keep related changes coherent
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.
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.