Skip to content
Skip to main content

Portfolio Stream

Beta

The WebSocket API is currently in beta. Features and endpoints are subject to change.

Overview

The Portfolio stream delivers real-time updates for active orders and positions - specifically changes driven by order activity (fills, partial fills, cancellations, acknowledgements). It does not deliver price-driven P&L changes; those come from the P&L Stream.

Endpoint: wss://webapi.tradezero.com/stream/portfolio

Incremental updates only

The Portfolio stream does not send a full snapshot of existing orders or positions when you first connect. See the recommended usage pattern below to avoid missing updates.


Because the stream delivers only incremental changes, fetch your current state from the REST API before subscribing:

  1. Open the WebSocket connection and start buffering incoming messages - do this first so you don't miss any updates that arrive while the REST call is in flight.
  2. Fetch current orders and positions via REST:
    • GET /v1/api/accounts/{accountId}/orders - every working order, including multi-day GTCs
    • GET /v1/api/accounts/{accountId}/positions - current open positions
  3. Apply any buffered WebSocket messages on top of the REST snapshot - this gives you a consistent starting state.

Usage

Step 1 - Connect and authenticate

Connect to the endpoint and complete the authentication handshake described in About WebSocket API. Once you receive CONNECTED, proceed to step 2.

Step 2 - Subscribe

Send a subscribe payload with the account ID and the channel types you want:

{
"accountId": "YOUR_ACCOUNT_ID",
"subscriptions": ["Order", "Position"]
}
ValueChannel
"Order"Order state changes (accepted, filled, partially filled, canceled)
"Position"Position changes driven by order fills

You can subscribe to one or both channels in a single message. To subscribe to additional accounts, send further subscribe payloads at any time - each one receives its own confirmation.

Channel names are case-insensitive

["order", "position"] (lowercase) is accepted the same as ["Order", "Position"]. Duplicate entries in the array (e.g. ["Order", "Order"]) are also accepted and treated as a single subscription. The subscriptions field must be a JSON array - sending a string returns INVALID_DATA.

Forgiving subscribe payload

Any extra fields you include in the subscribe payload beyond accountId and subscriptions are ignored by the server - only those two fields are read.

Step 3 - Receive the confirmation

The server responds with a meta message confirming the subscription:

{
"ts": 1700000016653,
"accountId": "TZP12345678",
"action": "meta",
"requestConfirmed": true
}

After this confirmation, order and position updates flow in as they occur.


Real-time updates

All update messages share the same top-level envelope:

FieldTypeDescription
tsnumberUnix timestamp in milliseconds
accountIdstringThe account the update belongs to
actionstringAlways "update" for data messages
subscriptionstring"Order" or "Position"

Order updates

Received whenever an order changes state.

{
"ts": 1771864795549,
"accountId": "TTE12345678",
"action": "update",
"subscription": "Order",
"order": {
"accountId": "TTE12345678",
"canceledQuantity": 0,
"clientOrderId": "my-buy-001",
"executed": 1,
"lastPrice": 398.41,
"lastQuantity": 1,
"lastUpdated": "2026-02-23T16:39:55.259+00:00",
"leavesQuantity": 0,
"limitPrice": 398.41,
"maxDisplayQuantity": 0,
"openClose": "Open",
"orderQuantity": 1,
"orderStatus": "Filled",
"orderType": "Limit",
"pegDifference": 0,
"pegOffsetType": "Price",
"priceAvg": 398.41,
"priceStop": 0,
"route": "SMART",
"securityType": "Stock",
"side": "Buy",
"startTime": "2026-02-23T16:37:05.909+00:00",
"strikePrice": 0,
"symbol": "TSLA",
"text": null,
"timeInForce": "Day",
"tradedSymbol": "TSLA"
}
}

The same shape is delivered for paper accounts, with accountId set to a TZP… value and route: "PAPER" (or "PAPERM" for multi-leg). Paper rows often carry text: "TRAFIX_SIM" instead of null.

Order field reference

FieldTypeDescription
accountIdstringAccount the order belongs to
canceledQuantitynumberQuantity canceled
clientOrderIdstringClient-assigned order identifier
executednumberTotal quantity filled so far
lastPricenumberPrice of the most recent fill
lastQuantitynumberQuantity of the most recent fill
lastUpdatedstringISO 8601 timestamp of the most recent change
leavesQuantitynumberRemaining unfilled quantity
limitPricenumberThe order's limit price
maxDisplayQuantitynumberDisplayed quantity for reserve/iceberg orders (0 if not applicable)
openClosestring"Open" or "Close"
orderQuantitynumberTotal ordered quantity
orderStatusstringCurrent status - see order lifecycle
orderTypestring"Limit", "Market", "Stop", etc.
pegDifferencenumberPeg offset amount (0 if not a pegged order)
pegOffsetTypestringPeg reference type (e.g. "Price")
priceAvgnumberVolume-weighted average fill price
priceStopnumberStop price for stop or stop-limit orders (0 otherwise)
routestringRouting destination - paper accounts return "PAPER" (single-leg / equity) or "PAPERM" (multi-leg). Live accounts return real venues such as "SMART", "CTDL", "SMARTO", "SMARTM", or "ARCA".
securityTypestring"Stock", "Option", "MLEG"
sidestring"Buy" or "Sell"
startTimestringISO 8601 timestamp when the order was placed
strikePricenumberStrike price for single-leg options (0 for equities)
symbolstringTicker symbol (underlying ticker for multi-leg orders)
textstringFree-text routing notes
timeInForcestring"Day", "GoodTillCancel", "ImmediateOrCancel", etc.
tradedSymbolstring | nullSymbol as executed; may differ from symbol for certain option routes
Multi-leg orders

For multi-leg ("MLEG") orders, symbol is the underlying ticker (e.g. "AAPL") and securityType is "MLEG". The individual leg details are not included in the WebSocket order update; use GET /v1/api/accounts/{accountId}/orders to retrieve the full leg structure.


Position updates

Received whenever a fill creates or modifies a position.

{
"ts": 1771864795549,
"accountId": "TTE12345678",
"action": "update",
"subscription": "Position",
"position": {
"id": "7260223163955259000",
"accountId": "TTE12345678",
"createdDate": "2026-02-23T16:39:59.559+00:00",
"dayOvernight": "Day",
"priceAvg": 398.41,
"priceClose": 0,
"priceOpen": 398.41,
"priceStrike": 0,
"putCall": "None",
"securityType": "Stock",
"shares": 1,
"side": "Long",
"symbol": "TSLA",
"updatedDate": "2026-02-23T16:39:55.259+00:00"
}
}

Position field reference

FieldTypeDescription
idstringUnique position identifier. Note: the REST position endpoints use positionId for the same value; the WebSocket stream uses id.
accountIdstringAccount the position belongs to
createdDatestringISO 8601 timestamp when the position was first opened
dayOvernightstring"Day" (opened today) or "Overnight" (carried from prior session)
priceAvgnumberAverage cost basis
priceClosenumber0 while the position is open (it is not a previous-session close). Populated only after the position is fully closed.
priceOpennumberPrice at which the position was originally opened
priceStrikenumberStrike price for options (0 for equities)
putCallstring"Put", "Call", or "None" for equities
securityTypestring"Stock" or "Option"
sharesnumberCurrent position size. Negative for short positions.
sidestring"Long" or "Short"
symbolstringTicker symbol; OCC format for options
updatedDatestringISO 8601 timestamp of the most recent update

Error handling

Subscription errors are returned as system messages ("@system": true) with status: "TERMINATED" or status: "INVALID_DATA". The connection stays open after these; you can send a corrected subscribe payload without reconnecting.

ScenarioStatusMessage
Account ID not found or not ownedTERMINATED"invalid account"
accountId sent as a number (e.g. 99999)TERMINATED"invalid account"
subscriptions array is empty []TERMINATED"subscription types not specified"
subscriptions field is omittedTERMINATED"subscription types not specified"
subscriptions contains an unrecognized string valueINVALID_DATADescription of the parse error
subscriptions is a string instead of an arrayINVALID_DATA"Error converting value ... to type 'ICollection[WsPortfolioSubscptionType]'"
{
"@system": true,
"ts": 1700000019288,
"status": "TERMINATED",
"message": "invalid account"
}

Multiple account subscriptions

Send additional subscribe payloads on the same connection to subscribe to more accounts. Each subscription receives its own meta confirmation:

{ "accountId": "ACCOUNT_A", "subscriptions": ["Order", "Position"] }
{ "accountId": "ACCOUNT_B", "subscriptions": ["Order"] }

Complete example

The pattern below implements the recommended REST-first approach: buffer WebSocket messages while fetching the REST snapshot, then apply them on top.

const accountId = 'YOUR_ACCOUNT_ID';
const apiKey = 'YOUR_TZ-API-KEY-ID';
const apiSecret = 'YOUR_TZ-API-SECRET-KEY';

const authHeaders = {
'TZ-API-KEY-ID': apiKey,
'TZ-API-SECRET-KEY': apiSecret,
};

// Local state - seeded from REST, kept in sync by WebSocket updates.
const orders = new Map<string, any>(); // keyed by clientOrderId
const positions = new Map<string, any>(); // keyed by id (WebSocket) / positionId (REST)

// Buffer messages that arrive while the REST fetch is in flight.
const messageBuffer: any[] = [];
let restReady = false;

function applyMessage(msg: any): void {
if (msg['@system']) {
switch (msg.status) {
case 'PENDING_AUTH':
ws.send(JSON.stringify({ key: apiKey, secret: apiSecret }));
break;
case 'CONNECTED':
ws.send(JSON.stringify({ accountId, subscriptions: ['Order', 'Position'] }));
break;
case 'FAILED_AUTH':
console.error('Auth failed:', msg.message);
ws.close();
break;
case 'TERMINATED':
case 'INVALID_DATA':
console.error('Subscribe error:', msg.status, msg.message);
break;
}
return;
}

if (msg.action === 'meta') {
console.log('Portfolio stream confirmed for', msg.accountId);
return;
}

if (msg.action === 'update' && msg.subscription === 'Order') {
orders.set(msg.order.clientOrderId, msg.order);
onOrderChange(msg.order);
} else if (msg.action === 'update' && msg.subscription === 'Position') {
// The stream uses `id`; REST endpoints use `positionId` for the same value.
positions.set(msg.position.id, msg.position);
onPositionChange(msg.position);
}
}

const ws = new WebSocket('wss://webapi.tradezero.com/stream/portfolio');

ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (!restReady) {
messageBuffer.push(msg);
} else {
applyMessage(msg);
}
};

// Fetch the REST snapshot in parallel - apply buffered messages after.
async function bootstrap(): Promise<void> {
const [ordersR, positionsR] = await Promise.all([
fetch(`https://webapi.tradezero.com/v1/api/accounts/${accountId}/orders`, { headers: authHeaders }),
fetch(`https://webapi.tradezero.com/v1/api/accounts/${accountId}/positions`, { headers: authHeaders }),
]);
if (!ordersR.ok) throw new Error(`orders bootstrap failed: ${ordersR.status}`);
if (!positionsR.ok) throw new Error(`positions bootstrap failed: ${positionsR.status}`);
const [ordersResp, positionsResp] = await Promise.all([ordersR.json(), positionsR.json()]);

// Both endpoints return wrapped envelopes: `{ orders: [...] }` and `{ positions: [...] }`.
for (const o of ordersResp.orders ?? []) orders.set(o.clientOrderId, o);
for (const p of positionsResp.positions ?? []) positions.set(p.positionId, p);

restReady = true;
for (const msg of messageBuffer) applyMessage(msg);
messageBuffer.length = 0;
}

bootstrap().catch(console.error);
The id / positionId key difference

REST position endpoints return positionId. The Portfolio WebSocket stream returns the same value as id. When merging WebSocket updates on top of a REST snapshot you need to reconcile these: either re-key the REST snapshot to use id, or query the REST endpoint after the first WebSocket position update to confirm the mapping.

For an end-to-end implementation of the connect → buffer → REST snapshot → apply pattern (with reconnection logic and full message handling), see the Portfolio Order Tracker recipe.