P&L Stream
The WebSocket API is currently in beta. Features and endpoints are subject to change.
Overview
The P&L stream delivers continuous updates to account value, leverage, and per-position unrealized P&L. Every time the market price of a held security changes, the stream pushes both an aggregate account-level update and a position-level update for the affected position.
Endpoint: wss://webapi.tradezero.com/stream/pnl
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 to an account
Send a JSON message with the account ID you want to stream:
{
"account": "YOUR_ACCOUNT_ID"
}
You can subscribe to additional accounts at any time by sending more { "account": "..." } messages on the same connection. Each account sends its own initial snapshot followed by its own stream of updates.
The P&L stream ignores any extra fields you include in the subscribe payload. Only the account field is read; all other fields are discarded. This makes it safe to send a richer object without stripping fields first.
Step 3 - Receive the initial snapshot
On successful subscription the server immediately sends a full snapshot of the account's current state:
{
"ts": 1700000000240,
"action": "init",
"target": "pnlReturn",
"pnlReturn": {
"accountValue": 996452.64,
"availableCash": 967066.39,
"optionCashUsed": 2293.00,
"usedLeverage": 0.03,
"allowedLeverage": 1,
"sharesTraded": 2546,
"exposure": 27093.25,
"dayUnrealized": 2731.58,
"dayRealized": 0,
"dayPnl": 2731.58,
"totalUnrealized": 5004.98,
"equityRatio": 1,
"positions": [
{
"positionId": "2260512190020399758",
"symbol": "AAPL",
"pnlCalc": {
"unrealizedPnL": 4.26,
"dayUnrealizedPnL": 3.88,
"pctPnLMove": 1.45,
"dayPctPnLMove": 1.32,
"exposure": 298.68
},
"realizedPnl": 0,
"dayRealizedPnl": 0
},
{
"positionId": "2260513134257710616",
"symbol": "NVDA260513P00232500",
"pnlCalc": {
"unrealizedPnL": -115.00,
"dayUnrealizedPnL": -115.00,
"pctPnLMove": -16.2,
"dayPctPnLMove": -16.2,
"exposure": -825.00
},
"realizedPnl": 0,
"dayRealizedPnl": 0
}
]
}
}
Option positions appear alongside equity positions in the positions array. The symbol field contains the full OCC-format symbol (e.g. NVDA260513P00232500). The exposure value for a short option position is negative.
Real-time updates
After the initial snapshot, the server pushes two types of update messages whenever a price change affects a held position.
Aggregate account update
The aggCalcs update carries account-level values. Only fields that changed from the previous snapshot are included - treat any absent field as unchanged.
{
"ts": 1700000000262,
"action": "update",
"target": "aggCalcs",
"aggCalcs": {
"accountValue": 996455.79,
"exposure": 27096.40,
"dayUnrealized": 2734.73,
"dayPnl": 2734.73,
"totalUnrealized": 5008.13
}
}
Position-level update
The position update carries P&L data for the single position whose price changed. Only fields within pnlCalc that changed from the prior snapshot are included.
{
"ts": 1700000000262,
"action": "update",
"target": "position",
"position": {
"positionId": "2260421072916766412",
"symbol": "NVDA",
"pnlCalc": {
"unrealizedPnL": 2562.00,
"dayUnrealizedPnL": 579.60,
"pctPnLMove": 12.09,
"dayPctPnLMove": 2.50,
"exposure": 23761.50
}
}
}
Aggregate and position updates are sent in pairs: for any given price tick the server sends one aggCalcs and one position message with matching ts values. The two messages may arrive consecutively or interleaved if multiple positions move simultaneously.
Outside regular trading hours the connection stays open and the initial snapshot is delivered normally. Price-driven updates arrive rarely or not at all while markets are closed. Premarket and after-hours sessions may produce updates at reduced frequency depending on venue activity.
Message type reference
action | target | Description |
|---|---|---|
"init" | "pnlReturn" | Full account snapshot - sent once per { account } subscribe |
"update" | "aggCalcs" | Partial account-level P&L update |
"update" | "position" | Partial position-level P&L update |
"error" | - | Subscription-level error (e.g. account not found) |
Field reference
pnlReturn (initial snapshot)
| Field | Type | Description |
|---|---|---|
accountValue | number | Total account value |
availableCash | number | Cash available for trading |
optionCashUsed | number | Cash reserved for open option positions |
usedLeverage | number | Current leverage multiplier in use |
allowedLeverage | number | Maximum permitted leverage |
sharesTraded | number | Total shares traded today |
exposure | number | Total market exposure (long − short) |
dayUnrealized | number | Unrealized P&L for the current session |
dayRealized | number | Realized P&L for the current session |
dayPnl | number | dayUnrealized + dayRealized |
totalUnrealized | number | Total unrealized P&L across all open positions |
equityRatio | number | Equity-to-account-value ratio |
positions | array | Per-position P&L entries (see below) |
Each entry in positions
| Field | Type | Description |
|---|---|---|
positionId | string | Unique position identifier |
symbol | string | Ticker symbol; OCC format for options (e.g. NVDA260513P00232500) |
pnlCalc | object | Current P&L calculations for this position |
pnlCalc.unrealizedPnL | number | Total unrealized P&L |
pnlCalc.dayUnrealizedPnL | number | Unrealized P&L since the session open |
pnlCalc.pctPnLMove | number | Total unrealized P&L as a percentage |
pnlCalc.dayPctPnLMove | number | Day unrealized P&L as a percentage |
pnlCalc.exposure | number | Market value of the position (negative for short/sold options) |
realizedPnl | number | Total realized P&L for this position |
dayRealizedPnl | number | Realized P&L from today's activity |
aggCalcs update fields
All fields are identical to the matching pnlReturn fields. Any field may be absent from an update - only values that changed since the last message are included.
Error handling
Subscription errors
If the { "account": "..." } payload is invalid, the server sends an error data message (not a system message):
{
"ts": 1700000008729,
"action": "error",
"message": "account not found"
}
| Scenario | Response type | message value |
|---|---|---|
| Account ID does not exist | action: "error" data message | "account not found" |
| Account not owned by authenticated credentials | action: "error" data message | "account not found" |
Numeric value sent as account (e.g. 12345) | action: "error" data message | "account not found" |
Empty account - TERMINATED
Sending an empty string as the account value returns a system-level TERMINATED message rather than a data error:
{
"@system": true,
"ts": 1700000004324,
"status": "TERMINATED",
"message": "Account value needs to be non-empty"
}
After TERMINATED the connection remains open. You can send a corrected { "account": "..." } payload without reconnecting.
Malformed subscribe payload - INVALID_DATA
If you send a value that cannot be deserialized as a subscribe request (for example, a plain string instead of a JSON object) the server sends INVALID_DATA with a description of the parse error:
{
"@system": true,
"ts": 1700000011585,
"status": "INVALID_DATA",
"message": "Error converting value \"this-is-not-a-valid-message\" to type 'TradeZero.Api.PnL.Models.PnlSubscribeRequest'. Path '', line 1, position 29."
}
The connection is not closed after INVALID_DATA. If you already have an active subscription, updates continue flowing. You can send a corrected subscribe payload at any time.
State management pattern
The P&L stream is designed around a local copy of pnlReturn that you seed from the initial snapshot and keep in sync using partial updates:
interface PnlState {
accountValue: number;
availableCash: number;
optionCashUsed: number;
usedLeverage: number;
allowedLeverage: number;
sharesTraded: number;
exposure: number;
dayUnrealized: number;
dayRealized: number;
dayPnl: number;
totalUnrealized: number;
equityRatio: number;
positions: Map<string, {
symbol: string;
pnlCalc: {
unrealizedPnL: number;
dayUnrealizedPnL: number;
pctPnLMove: number;
dayPctPnLMove: number;
exposure: number;
};
realizedPnl: number;
dayRealizedPnl: number;
}>;
}
let state: PnlState | null = null;
const ws = new WebSocket('wss://webapi.tradezero.com/stream/pnl');
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg['@system']) {
switch (msg.status) {
case 'PENDING_AUTH':
ws.send(JSON.stringify({ key: 'YOUR_TZ-API-KEY-ID', secret: 'YOUR_TZ-API-SECRET-KEY' }));
break;
case 'CONNECTED':
ws.send(JSON.stringify({ account: 'YOUR_ACCOUNT_ID' }));
break;
case 'FAILED_AUTH':
console.error('Auth failed:', msg.message);
ws.close();
break;
case 'TERMINATED':
console.error('Subscribe rejected:', msg.message);
break;
case 'INVALID_DATA':
// Note: connection stays open - existing subscription keeps delivering updates.
console.warn('Invalid subscribe payload:', msg.message);
break;
}
return;
}
if (msg.action === 'init' && msg.target === 'pnlReturn') {
// Build the initial state - seed the positions map.
// Keep `pnlCalc` nested so update messages (which carry the same shape)
// can be merged in directly without a flat/nested mismatch.
const snap = msg.pnlReturn;
state = {
...snap,
positions: new Map(
snap.positions.map((p: any) => [
p.positionId,
{
symbol: p.symbol,
pnlCalc: { ...p.pnlCalc },
realizedPnl: p.realizedPnl,
dayRealizedPnl: p.dayRealizedPnl,
},
]),
),
};
onStateChange(state);
return;
}
if (!state) return; // Updates before snapshot are discarded.
if (msg.action === 'update' && msg.target === 'aggCalcs') {
// Merge only the fields present in the update.
Object.assign(state, msg.aggCalcs);
onStateChange(state);
return;
}
if (msg.action === 'update' && msg.target === 'position') {
const { positionId, pnlCalc } = msg.position;
const existing = state.positions.get(positionId);
if (existing) {
Object.assign(existing.pnlCalc, pnlCalc);
}
onStateChange(state);
return;
}
if (msg.action === 'error') {
console.error('P&L subscription error:', msg.message);
}
};
For each price tick the server sends one aggCalcs and one position message with matching ts values. If you render UI after every message you may want to batch these: buffer both and render once after both have arrived within the same ts.
For end-to-end implementations on top of these messages, see:
- P&L Stream Dashboard - dedicated walk-through of the WebSocket handshake, initial snapshot, and incremental merge.
- Live Account Dashboard - composite dashboard that combines the P&L stream with REST snapshots from
/account,/positions, and/pnl, with auto-reconnect and a clean state machine.