Skip to content
Skip to main content

Positions & P&L

Retrieve open holdings, per-position profit and loss, closed lifecycles, and as-of historical snapshots for a TradeZero account.

Open holdings use two read-only endpoints:

  • GET /v1/api/accounts/{accountId}/positions - the holdings themselves: symbol, side, share count, average price, day-vs-overnight tag, plus option metadata (tradedSymbol, priceStrike, putCall) when the row is an option.
  • GET /v1/api/accounts/{accountId}/pnl - per-position realized / unrealized P&L plus the account-level totals (accountValue, availableCash, dayPnl, usedLeverage, exposure).

Both share row identity through positionId. The recommended join order is positionId, then tradedSymbol, then symbol.

This guide also covers Closed positions (GET /positions/closed) and Historical positions (GET /positions/historical?date=).

MCP exposes the same holdings via get_positions; pair with get_account_pnl for unrealized figures - Positions.

Open holdings​

At a glance​

MethodPathPurpose
GET/v1/api/accounts/{accountId}/positionsOpen holdings - symbol, side, quantity, average price, option metadata
GET/v1/api/accounts/{accountId}/pnlPer-position P&L plus account totals (accountValue, availableCash, dayPnl, …)
GET/v1/api/accounts/{accountId}/positions/closedClosed lifecycles - realized P&L, cumulative shares in/out (Closed positions)
GET/v1/api/accounts/{accountId}/positions/historicalHoldings as of a calendar date (Historical positions)
What /positions does not include

The Positions API only describes what is currently open. Use a different endpoint for everything else:

Paper vs live - same shape, a few behavioral notes

The request and response format for /positions and /pnl is identical on paper (TZP* accounts) and live: authentication, headers, error codes, and JSON response structure all match. Behavioral differences:

  • Paper accounts hold positions across sessions. Anything you bought on a previous paper session is still there next time you read /positions. Use the Flatten All Positions recipe to reset between exercises.
  • Paper P&L is simulated, not real. accountValue, dayPnl, unrealizedPnL etc. reprice against live quotes, but the cash and equity totals are reset to a fixed paper balance and do not reflect any actual money. Margin / borrow / commissions are similarly simulated.
  • Paper option quotes. On paper, option lots without a current quote appear in /positions but not in /pnl[]. Live accounts show every lot in both endpoints.
  • Order-acceptance rules differ where the docs explicitly say so. Equity request/response shapes match on paper and live. Session reject codes, route defaults when route is omitted, and historical retention differ - see Authentication → Paper vs live and Trading → Paper vs live. Locates work on live only (Authentication), some routes / TIFs / Mleg strategies are restricted on paper (Trading → Order types, times in force, and session hours, Options → Multi-leg paper restrictions), and GET /orders/start-date/{date} returns { "orders": [] } on paper because paper has no historical-order archive.

The no-market-orders-outside-regular-hours rule (covered below) applies to both paper and live - rejection text differs by environment (see Order types, times in force, and session hours).


Quick start​

The whole working API is two GETs. Both take the account ID in the URL path. Both return JSON. Neither has a body.

Read positions and PnL
curl 'https://webapi.tradezero.com/v1/api/accounts/TZP12345678/positions' \
-H 'Accept: application/json' \
-H 'TZ-API-KEY-ID: {YOUR_PUBLIC_KEY}' \
-H 'TZ-API-SECRET-KEY: {YOUR_SECRET}'

curl 'https://webapi.tradezero.com/v1/api/accounts/TZP12345678/pnl' \
-H 'Accept: application/json' \
-H 'TZ-API-KEY-ID: {YOUR_PUBLIC_KEY}' \
-H 'TZ-API-SECRET-KEY: {YOUR_SECRET}'

Authentication uses the same key / secret pair you use for the rest of the trading API. The endpoints accept only GET; non-GET methods return 404 or 405. OPTIONS is supported as a CORS preflight.

If you need real-time updates instead of point-in-time snapshots, skip the polling section below entirely and go straight to Live updates via WebSocket - the Portfolio and P&L streams give you the same data incrementally.


Order placement and off-hours trading​

Pre-market (before 9:30 AM ET), post-market (after 4:00 PM ET), and weekends are quiet windows. The position endpoints keep working, but POST /order behavior changes:

  • Market orders are rejected outside SMART Day regular hours (9:30A–4:00P ET). The HTTP response is still 200 OK with orderStatus: "Rejected". On live, text is R100. On paper, Market is R78. Nothing fills, nothing rests, no position appears.
  • Limit and StopLimit with timeInForce: "Day_Plus" (or GTC_Plus) rest into the extended-hours session when eligible - see Order types, times in force, and session hours.
  • Limit with timeInForce: "Day" is eligible from 4:00A ET on SMART. After 4:00P ET, Limit Day remains eligible and can rest. StopLimit with Day is eligible on live from 4:00A through RTH (after-hours → R100); on paper it is RTH-only (R80 outside). Outside RTH, send Day_Plus or GTC_Plus.

An off-hours market order returns 200 OK at the HTTP layer while the order is declined in the response body - /positions continues to report your existing overnight rows. Inspect orderStatus in the response body before assuming a fill; the HTTP status code alone does not confirm execution. The full SMART type × TIF × session matrix lives on Order types, times in force, and session hours.


Endpoint reference​

The base URL for production is https://webapi.tradezero.com. The TZ-API-KEY-ID and TZ-API-SECRET-KEY headers are required on every call. Both endpoints always respond with Content-Type: application/json; charset=utf-8; supplying a different Accept header has no effect on the response format.

Read open positions​

GET /v1/api/accounts/{accountId}/positions

Returns every open row on the account: stocks (long or short), single-leg option contracts, and any positions that came from filled multi-leg orders (the legs surface individually with their own positionIds).

Response envelope​

Every response is wrapped in a positions object whose value is the array of position rows - the top-level response is always { "positions": [...] }. Read response.positions to access the rows.

Response - mixed stock + option positions
{
"positions": [
{
"accountId": "TZP12345678",
"createdDate": "2026-05-12T19:00:20.4271758+00:00",
"dayOvernight": "Overnight",
"maintenanceRequirement": 0,
"marginRequirement": 0,
"positionId": "2260512190020399758",
"priceAvg": 294.42,
"priceClose": 0,
"priceOpen": 294.42,
"priceStrike": 0,
"putCall": "None",
"rootSymbol": null,
"securityType": "Stock",
"shares": 1,
"side": "Long",
"symbol": "AAPL",
"tradedSymbol": null,
"updatedDate": "2026-05-12T19:00:20.4307353+00:00"
},
{
"accountId": "TZP12345678",
"createdDate": "2026-05-12T19:31:12.4810776+00:00",
"dayOvernight": "Overnight",
"maintenanceRequirement": 0,
"marginRequirement": 0,
"positionId": "2260512193112437606",
"priceAvg": 5.72,
"priceClose": 0,
"priceOpen": 5.72,
"priceStrike": 703,
"putCall": "Call",
"rootSymbol": null,
"securityType": "Option",
"shares": 2,
"side": "Long",
"symbol": "QQQ",
"tradedSymbol": "QQQ260514C00703000",
"updatedDate": "2026-05-12T19:31:17.0098659+00:00"
}
]
}

If the account has no open positions, the server returns { "positions": [] }. The envelope is never omitted, even on a brand-new account.

Position-row fields​
FieldTypeStock valueOption value
accountIdstringthe account ID you queriedsame
positionIdstringnumeric-looking string (do not parse as int)same; the stable join key for /pnl and websockets
symbolstringthe ticker (e.g. "AAPL")the underlying root (e.g. "QQQ"), not the OCC
tradedSymbolstringnullthe OCC contract (e.g. "QQQ260514C00703000")
rootSymbolstringnull in current production datanull in current production data - parse tradedSymbol instead
securityTypestring"Stock""Option"
sidestring"Long" or "Short""Long" or "Short" (e.g. a sold-to-open call is "Short")
sharesnumbersize of the position (see "Short rows" note below). Treat as number (the wire type is JSON number, not int) so fractional-share rows on supported securities round-trip cleanly.size in contracts (1 contract = 100 shares); always integer-valued
priceAvgnumbervolume-weighted average entry priceaverage premium paid (or received, for shorts)
priceOpennumbersession open price snapshot at row creationsame
priceClosenumber0 while the position is still open - not a real closesame
priceStrikenumber0the strike price as a plain number (e.g. 703, not 00703000)
putCallstring"None""Call" or "Put"
dayOvernightstring"Day" for trades opened today, "Overnight" once they rollsame
marginRequirementnumbermargin reserved for the position; 0 on cash accountsame
maintenanceRequirementnumbermaintenance reserved; 0 on cash accountsame
createdDatestringISO 8601 with offset (e.g. "...+00:00")same
updatedDatestringISO 8601, refreshed on every fill that moves the rowsame

A few behaviors to be aware of when you build against these fields:

  • priceClose is 0 while a position is open. It is populated only after the position has closed, so for any open row treat it as "not applicable" and use real-time quotes for current pricing.
  • Options carry the root in symbol and the OCC in tradedSymbol. Use symbol for the underlying ticker and tradedSymbol for the contract identifier. The OCC string in tradedSymbol follows the standard OCC layout: 1-6 characters of root, 6-digit YYMMDD expiry, single-character C/P, then an 8-digit strike where the last 3 digits are the milli-cents fractional component (00703000 = $703.000). For example, QQQ260717C00703000 is a QQQ 2026-07-17 Call at strike 703. The OCC option symbol recipe shows a parser.
  • priceStrike is the human strike, not the OCC fragment. For QQQ260514C00703000 the row reports priceStrike: 703, not 703000 and not 00703000. Fractional strikes are returned as numbers - a $7.50 strike comes back as 7.5.
  • Read side to determine direction; use Math.abs(shares) for size. side is the canonical indicator of whether a position is "Long" or "Short"; shares carries the size of the position. Treating shares as a magnitude alongside side produces a clean direction-and-size pair that holds for both stocks and options.
  • Multi-leg option fills land as individual rows. A securityType: "Mleg" order does not produce a single combined position. Each filled leg appears as its own option row with its own positionId. To reconstruct a spread or covered call in the UI, group rows by the originating clientOrderId from /orders, or join on root symbol + same-day fills.
  • Sub-second timestamps. createdDate and updatedDate come back with seven fractional digits and a +00:00 suffix. Standard JSON / ISO 8601 time parsers handle this correctly; libraries that truncate to millisecond precision are still fine for display, but pay attention if you are diffing timestamps for strict ordering.
Filtering positions​

The /positions endpoint always returns the complete list of open positions for the account - there are no server-side filters and no querystring parameters. Apply any filtering you need on the client side after receiving the response.

Filtering patterns
// Stocks vs options
const stocks = positions.filter((p) => p.securityType === 'Stock');
const options = positions.filter((p) => p.securityType === 'Option');

// Direction
const longs = positions.filter((p) => p.side === 'Long');
const shorts = positions.filter((p) => p.side === 'Short');

// Day trades vs overnight holds
const dayTrades = positions.filter((p) => p.dayOvernight === 'Day');
const overnights = positions.filter((p) => p.dayOvernight === 'Overnight');

// All option legs on a specific underlying (symbol = root on option rows)
const qqqLegs = positions.filter(
(p) => p.securityType === 'Option' && p.symbol === 'QQQ',
);

// Net share delta for NVDA stock lots
const nvdaSize = positions
.filter((p) => p.symbol === 'NVDA' && p.securityType === 'Stock')
.reduce((total, p) => total + p.shares, 0);

Read per-position P&L​

GET /v1/api/accounts/{accountId}/pnl

Returns account-level totals plus a pnl[] array of per-lot P&L rows. Rows are keyed by positionId and join cleanly with /positions. The two endpoints describe overlapping but not identical sets:

  • /positions returns every open lot the account holds, including positions the system has no current quote for (e.g. paper option positions on illiquid contracts, expired options awaiting settlement).
  • /pnl returns rows for every lot the system can currently mark to market - i.e. lots with a usable quote - plus any lots that have been closed earlier today (those rows carry exposure: 0 and continue to report realized P&L for the rest of the session).

On a live account the two are 1:1 by positionId. On paper - and for any option lot that doesn't currently have a market price - /pnl can have fewer rows than /positions. Always join by positionId and treat a missing row as "no current P&L data".

note

This is the same endpoint documented under Account Info → Retrieve Account Values and Profit/Loss. The account-level aggregate fields (accountValue, availableCash, usedLeverage, etc.) are covered in more detail there.

Response - account with three open positions
{
"accountValue": 994282.71,
"allowedLeverage": 1,
"availableCash": 968814.74,
"dayPnl": 561.65,
"dayRealized": 0,
"dayUnrealized": 561.65,
"equityRatio": 1,
"exposure": 24038.35,
"optionCashUsed": 0,
"pnl": [
{
"positionId": "2260512190020399758",
"symbol": "AAPL",
"exposure": 293.65,
"realizedPnl": 0,
"unrealizedPnL": -0.77,
"dayRealizedPnl": 0,
"dayUnrealizedPnL": -1.15,
"pctPnLMove": -0.26,
"dayPctPnLMove": -0.39
},
{
"positionId": "2260512193112437606",
"symbol": "QQQ260514C00703000",
"exposure": 1429.62,
"realizedPnl": 0,
"unrealizedPnL": 285.62,
"dayRealizedPnl": 0,
"dayUnrealizedPnL": 0,
"pctPnLMove": 24.97,
"dayPctPnLMove": 0
}
],
"sharesTraded": 0,
"totalUnrealized": 2830.05,
"usedLeverage": 0.02
}

If there are no open positions, pnl is [] and the day totals are mostly 0; accountValue and availableCash still reflect the cash balance.

Account-level totals​
FieldDescription
accountValueTotal equity: cash + market value of open positions, refreshed in real time.
availableCashCash that could be deployed into a new order today, before borrow / margin.
allowedLeverageMaximum gross leverage permitted on the account (1 on cash, higher on margin).
usedLeverageCurrent gross exposure / equity. 0 on a flat account; equal to exposure / accountValue otherwise.
equityRatioEquity-to-account-value ratio. 1 when the account carries no credit; drops below 1 when you carry credit.
exposureTotal absolute notional across every open position (sum of per-row exposure).
optionCashUsedCash currently tied up in open option positions (premium paid for longs, collateral for shorts).
dayPnldayRealized + dayUnrealized for the current trading day.
dayRealizedRealized P&L closed out today.
dayUnrealizedMark-to-market P&L on positions still open at the end of the read.
totalUnrealizedLifetime unrealized P&L across every still-open row.
sharesTradedTotal shares that traded today across all symbols (informational; not a position count).
Per-position P&L row​
FieldDescription
positionIdSame key as the /positions row - join here.
symbolThe OCC for option positions, the equity ticker for stocks. Equals tradedSymbol from /positions for options, or symbol for stocks.
exposureAbsolute market value of the position right now.
realizedPnlLifetime realized P&L on this position (0 until part of the position is closed).
unrealizedPnLLifetime unrealized P&L on this position.
dayRealizedPnlRealized portion attributable to today.
dayUnrealizedPnLUnrealized portion attributable to today's price move.
pctPnLMoveunrealizedPnL as a percentage of cost basis, in percent (12.0 means +12%).
dayPctPnLMoveSame percentage scoped to today's move.

Note the casing: unrealizedPnL and dayUnrealizedPnL are camelCase with capital L, while realizedPnl and dayRealizedPnl end in lowercase Pnl. Keep the casing exact when reading these fields from the response.


Joining /positions and /pnl​

The two endpoints are intentionally complementary. To build a unified table - the kind a trading screen renders - zip them together on three keys in fallback order:

  1. positionId - identical between the two endpoints. Use this first.
  2. tradedSymbol - matches pnl[].symbol for option rows.
  3. symbol - matches pnl[].symbol for stock rows when positionId is unavailable.
Join pattern (TypeScript pseudocode)
type Position = { positionId: string; symbol: string; tradedSymbol: string | null; /* ... */ };
type PnlRow = { positionId: string; symbol: string; unrealizedPnL: number; /* ... */ };

function joinPositionsAndPnl(positions: Position[], pnl: PnlRow[]) {
const byPositionId = new Map(pnl.map((r) => [r.positionId, r]));
const bySymbol = new Map(pnl.map((r) => [r.symbol, r]));

return positions.map((p) => {
const match =
byPositionId.get(p.positionId) ??
(p.tradedSymbol ? bySymbol.get(p.tradedSymbol) : undefined) ??
bySymbol.get(p.symbol);
return { ...p, pnl: match };
});

}
Join on positionId, not symbol

positionId is the only reliable join key. The bySymbol fallbacks in the snippet above are defensive guards for the narrow race window where a fresh fill appears in one endpoint but not the other yet. In normal operation, positionId resolves every row.

symbol alone is not unique - an account with two open NVDA lots carries two /positions rows and two /pnl rows, both with symbol: "NVDA" but different positionId values. A symbol-keyed Map keeps only the last entry, so the bySymbol fallback would misattribute P&L if more than one lot exists. Always prefer positionId; treat the other fallbacks as last resorts.

Reading both endpoints back-to-back is fast, but the two snapshots are not from the same instant. If a fill lands between them, /positions may show a row that /pnl does not yet, or vice versa, for one or two seconds. Treat any unmatched row as "still settling" and reconcile on the next poll.


Live updates via WebSocket​

The REST endpoints are snapshots. Anything that needs to react in real time - a portfolio screen or a P&L ticker - should subscribe to the WebSocket streams instead of polling /positions. There are two streams, and they intentionally split position changes by what causes the change:

StreamEndpointWhat it pushes
Portfoliowss://webapi.tradezero.com/stream/portfolioOrder state changes plus position rows driven by fills
P&Lwss://webapi.tradezero.com/stream/pnlAccount totals and per-position P&L on every price tick

Both streams use the same authentication flow described on the WebSocket introduction page: connect, send { "key": ..., "secret": ... }, then send the per-stream subscribe message.

The bootstrap pattern​

The two streams behave differently on connect:

  • Portfolio sends incremental updates only - it never delivers a full snapshot when you subscribe. To know about orders and positions that already exist, you must read REST first.
  • P&L sends an init message with the full account snapshot and every per-position P&L row, so a separate REST call is technically optional - but reading GET /pnl is still useful as a fallback if you reconnect.

A stable client is built like this:

  1. Open both WebSockets and authenticate. Start buffering incoming messages immediately - do not process them yet.
  2. Read the REST snapshots in parallel. For the Portfolio stream, you need GET /orders (today's order book - all statuses - plus working multi-day GTCs; filter client-side for a blotter) and GET /positions (open positions). For the P&L stream, the init WebSocket message itself carries the account totals; GET /pnl is the equivalent REST snapshot if you prefer to seed from REST.
  3. Apply buffered WebSocket messages in arrival order on top of the REST snapshot. A Portfolio Order update for a clientOrderId you don't have yet is a new order; a Position update for a positionId you don't have yet is a new fill creating a position. A P&L position update for a positionId is a price refresh; an aggCalcs update is an account-totals refresh.
  4. From then on, react to messages live. Fall back to a one-shot REST refresh (GET /orders + GET /positions) if the connection drops or you detect a sequence gap.

The reason for buffering before the REST call (rather than subscribing first and fetching REST second) is that changes that land between the snapshot and the subscription would otherwise be lost - a fill that happens during the GET /positions round-trip would not be reflected in your client.

Bootstrap skeleton
const portfolioWS = await connectAndAuth('/stream/portfolio');
const pnlWS = await connectAndAuth('/stream/pnl');

// Buffer first - do NOT process yet.
const buffered: Message[] = [];
portfolioWS.on('message', (m) => buffered.push(m));
pnlWS.on('message', (m) => buffered.push(m));

// Subscribe with the field name each stream actually requires:
// - Portfolio uses `accountId`
// - P&L uses `account`
portfolioWS.send({accountId, subscriptions: ['Order', 'Position']});
pnlWS.send({account: accountId});

// Bootstrap from REST: orders + positions for the Portfolio stream.
// (The P&L stream sends its own `init` snapshot, so you can skip GET /pnl
// here unless you want a REST fallback path on reconnect.)
const headers = {
'TZ-API-KEY-ID': '…YOUR_KEY…',
'TZ-API-SECRET-KEY': '…YOUR_SECRET…',
};

const [ordersRes, positionsRes] = await Promise.all([
fetch(`https://webapi.tradezero.com/v1/api/accounts/${accountId}/orders`, {headers}).then((r) => r.json()),
fetch(`https://webapi.tradezero.com/v1/api/accounts/${accountId}/positions`, {headers}).then((r) => r.json()),
]);

const state = seedFromRestSnapshot(ordersRes.orders, positionsRes.positions);
for (const m of buffered) state.apply(m);
buffered.length = 0;
portfolioWS.on('message', (m) => state.apply(m));
pnlWS.on('message', (m) => state.apply(m));

REST and WebSocket field reference​

REST and WebSocket payloads describe the same positions, with each surface tuned for its delivery model: REST returns full snapshots, the Portfolio stream pushes order-driven changes, and the P&L stream pushes price-driven recalculations. Use the field mapping table below when you integrate both REST and streaming.

ConceptREST /positions rowPortfolio WS position updateP&L WS position update
Position identitypositionIdidpositionId
Symbol (stock)symbolsymbolsymbol
Symbol (option)symbol = root, tradedSymbol = OCCsymbolsymbol = OCC
Short encodingside: "Short" is the canonical direction. shares may come back as a signed number (negative for short option positions) or unsigned for stocks - always pair side with Math.abs(shares) for size.side: "Short", signed shares permittedn/a (P&L stream is unsigned)
Margin / maintenance fieldsmarginRequirement, maintenanceRequirementnot deliverednot delivered
P&L row shapeflat: { unrealizedPnL, dayUnrealizedPnL, ... }n/anested: { pnlCalc: { unrealizedPnL, dayUnrealizedPnL, ... } }
Snapshot array namepnln/a (incremental only)positions (inside pnlReturn on the initial init message)
Account-id field on subscribepath parameter accountIdsubscribe body accountIdsubscribe body account

A few field-name details to map carefully when you bridge the two surfaces:

  • The Portfolio stream uses id as the position identifier on its Position updates, while REST and the P&L stream use positionId. Normalize on receipt.
  • The P&L stream's subscribe message uses the field name account; the Portfolio stream's subscribe message uses accountId. Use each as documented per stream.

Map both surfaces to a common internal format and the rest of your client can treat all three as a single shape.

When REST polling is still appropriate​

Pure REST polling is the right call when:

  • You only need an occasional snapshot (a daily report, a webhook handler, a scheduled cron).
  • You cannot maintain a long-lived WebSocket (constrained runtime, serverless, restrictive corporate proxy).
  • You want a simple read-only audit script that does not need to track changes.

Both REST endpoints are idempotent and tolerate moderate parallel reads, but the API does enforce per-API-key rate limits - 3/s for both /pnl and /positions. See API Rate Limits. A reasonable default cadence is 5 seconds for a background portfolio refresh, with 1.5 to 2 second bursts for the 30 seconds immediately after a POST /order so a freshly-filled row appears quickly. Anything tighter than ~1 second risks 429 responses and adds load without giving you newer data.


Error responses​

ConditionStatusBody
Missing TZ-API-KEY-ID or TZ-API-SECRET-KEY404Not found\n (plain text)
Both auth headers present but values are invalid404Not found\n
Account ID does not match authenticated credentials404Not found\n
Account ID does not exist (e.g. TZP99999X)404Not found\n
Account ID is empty (/accounts//positions)404404 page not found
Account ID has whitespace, quotes, or extra trailing characters404Not found\n
POST / PUT / DELETE / PATCH against /positions404 or 405empty or 405 method not allowed
HEAD against /positions405empty
OPTIONS against /positions or /pnl200empty (CORS preflight)

Two things worth knowing when you handle errors:

  • Auth failures and unauthorized account access both return 404 Not Found. This protects account IDs from enumeration. If a previously working call starts returning 404, check credentials and account ID first.
  • Error bodies are text/plain, not JSON. Read res.text() and branch on the Content-Type header before parsing.

URL handling is permissive: lowercase or mixed-case account IDs (tzp12345678), lowercase header names (tz-api-key-id), and trailing slashes on the path all return 200 OK with the same positions list.


Recipes​

A few flows from the Quickstart Recipes gallery demonstrate these endpoints end-to-end:

  • Flatten All Positions. Read /positions, send a closing POST /order for every row, re-read until the list is empty. The cleanest way to reset a paper account between exercises.
  • Live Account Dashboard. Combines /account/{accountId}, /positions, and /pnl into a single periodic snapshot - a useful starting point for a polling-style monitor.
  • Stop-Loss on a Long Position. Reads /positions, attaches a stop order at a configurable percentage below priceAvg, and watches the row disappear when the stop is hit. The cleanest single-position-lifecycle example in the gallery.
  • OCC Option Symbol. Parses the tradedSymbol field on option rows into root + expiry + put/call + strike. Pairs with the option-row notes above.
  • Closed Positions Recap. Pulls /positions/closed for realized P&L and trade history - useful when reconciling overnight rollovers against what filled.

A quiet account reconciles as accountValue ≈ availableCash + sum(per-row exposure) + (option premium balance) across /account/{accountId}, /positions, and /pnl. Snapshots taken seconds apart can differ while fills are in flight; the totals match when no orders are working.

Closed positions​

What closed positions are for​

Use GET /positions/closed when you need:

  • Realized P&L by symbol lifecycle - the net result after the account is flat on that symbol.
  • Share accounting across round-trips - total shares bought or covered (sharesIn) and sold or shorted (sharesOut) within the lifecycle.
  • Blended entry and exit prices - share-weighted averages when a symbol was traded more than once before going flat.
  • A closed-trades or session-recap screen - aggregated lifecycle rows rather than per-fill order lines.

What closed positions do not include​

The route is a read-only snapshot of closed lifecycles. It does not push updates, accept writes, or filter on the server.

You needUse instead
Open lots and current sizeGET /positions
Holdings as of a calendar dateGET /positions/historical (date required)
Unrealized P&L and account day totalsGET /pnl (dayRealized, dayPnl, per-lot unrealized)
Individual fills and order statusGET /orders or GET /orders/start-date/{date}
Filter by symbol, date range, or page on the serverNot supported - the endpoint always returns the full closedPositions[] list for the account. Unknown query parameters (for example ?symbol=AAPL&limit=10) are ignored and the unfiltered list is returned. Filter client-side after download.
Real-time fill notificationsPortfolio WebSocket for order and position events; re-read /positions/closed after a close for lifecycle-level realized totals.
Only today's closed trades/pnl exposes session-scoped dayRealized. /positions/closed returns every closed lifecycle on record for the account. On paper, records persist across sessions and the list grows over time.
Open, close, or modify positionsPOST /order - this route accepts GET only.

At a glance​

MethodPathWhat it does
GET/v1/api/accounts/{accountId}/positions/closedRetrieve closed positions - flat lifecycles with realized P&L, cumulative sharesIn / sharesOut, and blended priceOpen / priceClose.

Full OpenAPI reference: Retrieve Closed Positions.

Where each kind of P&L lives

The Closed Positions API describes realized P&L on lifecycles that are already flat. For everything else:


Quick start​

The entire endpoint is a single GET request. It takes the account ID in the URL path, returns JSON, and has no request body.

Read closed positions
curl 'https://webapi.tradezero.com/v1/api/accounts/TZP12345678/positions/closed' \
-H 'Accept: application/json' \
-H 'TZ-API-KEY-ID: {YOUR_PUBLIC_KEY}' \
-H 'TZ-API-SECRET-KEY: {YOUR_SECRET}'

Authentication uses the same key / secret pair as the rest of the trading API - see Authentication. The endpoint accepts only GET; non-GET methods return 404 or 405, and OPTIONS is supported as a CORS preflight. Lowercase or mixed-case account IDs, lowercase header names, and a trailing slash on the path all return 200 OK with the same list.

Before you integrate

Two behaviors shape how you should parse and aggregate responses. Read The aggregation model and positionId handling before you write client code:

  1. Records are per-symbol-lifecycle aggregates, not per-trade. realized, sharesIn, and sharesOut are cumulative. A symbol can carry more than one record when lifecycles reset - sum across every record for a symbol rather than reading the first match.
  2. positionId is a large JSON number on the wire. In JavaScript and other IEEE-754 runtimes, values above Number.MAX_SAFE_INTEGER must be held as a string to preserve the exact identifier. See positionId handling.

Endpoint reference​

The base URL for production is https://webapi.tradezero.com. The TZ-API-KEY-ID and TZ-API-SECRET-KEY headers are required on every call. The endpoint always responds with Content-Type: application/json; supplying a different Accept header has no effect on the response format.

Read closed positions​

GET /v1/api/accounts/{accountId}/positions/closed

Returns every closed (flat) lifecycle TradeZero has on record for the account. The only path parameter is {accountId}. There are no supported query parameters - the response is always the complete closedPositions[] array. If you pass unknown query strings (such as ?symbol=, ?from=, ?limit=), they are ignored and the full list is still returned. Apply symbol, date, or pagination filters in your client after download.

Response envelope​

The response is a JSON object whose closedPositions value is the array of records - the top-level shape is always { "closedPositions": [...] }. Read response.closedPositions to access the rows. (Note the key is closedPositions, not positions.)

Response - multiple closed symbols
{
"closedPositions": [
{
"accountId": "TZP12345678",
"createdDate": "2026-06-26T20:27:29.9207954+00:00",
"dayOvernight": "Day",
"maintenanceRequirement": 0,
"marginRequirement": 0,
"positionId": "2260626202729920768",
"priceAvg": 0,
"priceClose": 282.0618181818182,
"priceOpen": 282.3288636363636,
"priceStrike": 0,
"putCall": "None",
"realized": -11.75,
"rootSymbol": null,
"securityType": "Stock",
"shares": 0,
"sharesIn": 44,
"sharesOut": 44,
"side": "Long",
"symbol": "AAPL",
"tradedSymbol": null,
"updatedDate": "2026-06-26T20:30:45.9731591+00:00"
},
{
"accountId": "TZP12345678",
"createdDate": "2026-06-26T19:58:55.2800054+00:00",
"dayOvernight": "Day",
"maintenanceRequirement": 0,
"marginRequirement": 0,
"positionId": "2260626195855280600",
"priceAvg": 2.19,
"priceClose": 2.18,
"priceOpen": 2.19,
"priceStrike": 0,
"putCall": "None",
"realized": -9.15,
"rootSymbol": null,
"securityType": "Stock",
"shares": 0,
"sharesIn": 915,
"sharesOut": 915,
"side": "Long",
"symbol": "UWMC",
"tradedSymbol": null,
"updatedDate": "2026-06-26T19:59:06.1395114+00:00"
}
]
}

If the account has no closed positions on record, the response is { "closedPositions": [] }. The envelope is never omitted.

positionId shown as a string

The example above shows positionId as a JSON string because that is the recommended representation in application code. On the wire it arrives as a bare number; see positionId handling for integration guidance.

Closed-record fields​
FieldTypeDescription
accountIdstringThe account that owns the record.
positionIdnumberIdentifier for the lifecycle. Arrives as a large JSON number - hold it as a string in client code (details). A new value may be issued when a symbol is re-opened from flat.
symbolstringThe ticker (e.g. "AAPL"). For options this is the underlying root (e.g. "SPY"), not the contract - the OCC contract is in tradedSymbol. See Options and multi-leg strategies.
tradedSymbolstring | nullnull for equities; the OCC contract for options (e.g. "SPY260717C00738000"). This is the per-contract key to aggregate options on.
rootSymbolstring | nullnull on both equity and option records. Use symbol for the underlying and tradedSymbol for the option contract.
securityTypestring"Stock" for equities; "Option" for option contracts (single-leg and each leg of a multi-leg strategy). Same field set as open positions.
sidestring"Long" or "Short". See Side on closed records.
sharesnumberAlways 0 - these are flat lifecycles. Open size lives on /positions.
sharesInnumberCumulative quantity bought into the lifecycle (buys and covers). For options this is a count of contracts, not underlying shares. Accumulates across trips.
sharesOutnumberCumulative quantity sold out of the lifecycle (sells and shorts). For options this is a count of contracts. Accumulates across trips.
priceOpennumberQuantity-weighted blended average entry price. For options this is the per-share option premium (e.g. 11.04), not the per-contract cost.
priceClosenumberQuantity-weighted blended average exit price. For options this is the per-share option premium.
priceAvgnumberEntry average on single-trip lifecycles; 0 when the lifecycle spans multiple trips. Use priceOpen / priceClose for blended entry and exit on every record (details).
realizednumberCumulative net realized P&L for the lifecycle, in account currency. Positive is a gain. For options this already includes the ×100 contract multiplier - realized ≈ (priceClose − priceOpen) × 100 × contracts on a long lifecycle.
priceStrikenumberOption strike as a plain number (e.g. 738); 0 for equities.
putCallstring"Call" or "Put" for options; "None" for equities.
dayOvernightstring"Day" or "Overnight" classification for the lifecycle.
marginRequirementnumberMargin reserved; 0 on a flat record.
maintenanceRequirementnumberMaintenance reserved; 0 on a flat record.
createdDatestringISO 8601 with offset, seven fractional digits (e.g. "...+00:00"). When the record was created.
updatedDatestringISO 8601. Last time the record was written. See When records update for how to detect changes.

Every returned record has shares equal to 0, and sharesIn equal to sharesOut (a flat lifecycle is balanced). realized is a finite number, and priceOpen / priceClose are positive on flat records.


The aggregation model​

This is the core aggregation behavior. The endpoint returns one record per symbol lifecycle, folding repeated activity on that symbol into that record:

  • realized is the cumulative net P&L of the lifecycle, not the result of one trade.
  • sharesIn / sharesOut are running totals that grow as you keep trading the symbol.
  • priceOpen / priceClose are share-weighted blended averages across all entries and all exits, not the prices of a single trade.

So a symbol traded repeatedly in a session shows up as one record whose sharesIn / sharesOut are the sums of every round-trip and whose realized is the net of them all. In the example above, the AAPL record reports sharesIn: 44, sharesOut: 44 with realized: -11.75 - the net of many round-trips folded into one row.

Aggregate realized P&L per symbol
type ClosedRecord = {
symbol: string;
realized: number;
sharesIn: number;
sharesOut: number;
};

function realizedBySymbol(records: ClosedRecord[]) {
const totals = new Map<string, {realized: number; sharesIn: number; sharesOut: number}>();
for (const r of records) {
const t = totals.get(r.symbol) ?? {realized: 0, sharesIn: 0, sharesOut: 0};
t.realized += r.realized;
t.sharesIn += r.sharesIn;
t.sharesOut += r.sharesOut;
totals.set(r.symbol, t);
}
return totals;
}
Equities group by symbol; options group by tradedSymbol

The grouping above is correct for equities. On option records symbol is the underlying root, so the same code would merge unrelated contracts. Aggregate options on tradedSymbol instead - see Options and multi-leg strategies.

More than one record per symbol​

When a symbol is re-opened after going flat, the next lifecycle may receive a new positionId, and the array can carry more than one record for the same symbol. Aggregate across every record for a symbol rather than calling records.find(r => r.symbol === 'AAPL').

Always aggregate across every record for a symbol - sum sharesIn, sharesOut, and realized.

Side on closed records​

side reports "Long" or "Short". Short-and-cover activity on a symbol is reflected in the same lifecycle record: the short open adds to sharesOut and the cover adds to sharesIn. Use realized for the economic result and the sharesIn / sharesOut pair for share accounting.


Options and multi-leg strategies​

Closed option lifecycles use the same envelope and the same fields as equities, with a few option-specific conventions to know before you aggregate:

  • symbol is the underlying root (e.g. "SPY"); the OCC contract is in tradedSymbol (e.g. "SPY260717C00738000"). rootSymbol is null.
  • securityType is "Option", putCall is "Call" or "Put", and priceStrike carries the strike.
  • sharesIn / sharesOut count contracts, not underlying shares - one contract round-tripped is sharesIn: 1, sharesOut: 1.
  • priceOpen / priceClose / priceAvg are per-share option premiums (e.g. 11.04), while realized is the full dollar P&L and already includes the ×100 contract multiplier.

Single-leg options​

A one-contract round-trip (buy to open, sell to close) comes back as a single record:

Single-leg call, closed flat
{
"symbol": "SPY",
"tradedSymbol": "SPY260717C00738000",
"rootSymbol": null,
"securityType": "Option",
"side": "Long",
"putCall": "Call",
"priceStrike": 738,
"shares": 0,
"sharesIn": 1,
"sharesOut": 1,
"priceOpen": 11.04,
"priceClose": 11.00,
"priceAvg": 11.04,
"realized": -4.00,
"positionId": "2260629145927622015"
}

Here realized = (priceClose − priceOpen) × 100 × contracts = (11.00 − 11.04) × 100 × 1 = −4.00. The prices are per-share premiums; the ×100 multiplier turns them into dollars.

Multi-leg strategies​

A multi-leg order (vertical, straddle, condor, …) does not produce one combined record. Each leg closes as its own option lifecycle - one record per OCC contract, each with its own positionId, side, priceStrike, and realized. There is no strategy-level grouping field; every leg shares the same underlying in symbol.

The example below is a 1-wide bull-call vertical (buy the 740 call, sell the 741 call) opened and then closed flat. It comes back as two records:

Bull-call vertical, closed flat (one record per leg)
{
"closedPositions": [
{
"symbol": "SPY",
"tradedSymbol": "SPY260717C00740000",
"securityType": "Option",
"side": "Long",
"putCall": "Call",
"priceStrike": 740,
"shares": 0,
"sharesIn": 1,
"sharesOut": 1,
"priceOpen": 9.83,
"priceClose": 9.78,
"realized": -5.00,
"positionId": "2260629145931279317"
},
{
"symbol": "SPY",
"tradedSymbol": "SPY260717C00741000",
"securityType": "Option",
"side": "Short",
"putCall": "Call",
"priceStrike": 741,
"shares": 0,
"sharesIn": 1,
"sharesOut": 1,
"priceOpen": 9.21,
"priceClose": 9.24,
"realized": -3.00,
"positionId": "2260629145931318642"
}
]
}

The strategy's net realized is the sum of its legs: −5.00 + −3.00 = −8.00. The short leg (the 741 call) opened by selling - adding to sharesOut - and closed by buying back - adding to sharesIn, so it reports side: "Short" and is still balanced at sharesIn: 1, sharesOut: 1.

Aggregate options by tradedSymbol, not symbol

Every option record carries the underlying in symbol, so grouping closed records by symbol collapses every contract on that underlying - and the underlying stock itself - into one bucket. To total a specific contract, aggregate on tradedSymbol. To total a multi-leg strategy, sum the realized of its leg records (legs share symbol but differ by tradedSymbol, priceStrike, and side).


positionId handling​

positionId arrives on the wire as a bare JSON number such as 2260626202729920768. Values above Number.MAX_SAFE_INTEGER (9007199254740991) cannot be represented exactly in JavaScript and other IEEE-754 double-precision runtimes. If your client uses the default JSON.parse, the parsed value may differ from the value on the wire:

JSON.parse('{"positionId": 2260626202729920768}').positionId;
// → 2260626202729920800

For exact identifiers in JavaScript, treat positionId as a string in your application model - for example by reading the raw response text, using a JSON parser with bigint support, or coercing to string before use. A stable display or grouping key is the combination of positionId and createdDate.

Integration guidance:

  • Hold positionId as an opaque string in maps, joins, and UI keys.
  • Do not rely on a native number type for equality or deduplication when the wire value exceeds the safe integer range.
  • On Open holdings, positionId is returned as a string; the same string-handling approach works across both endpoints.

priceAvg​

On a single-trip lifecycle, priceAvg matches the entry average - for example the UWMC record above shows priceAvg: 2.19. When a symbol is traded multiple times in one lifecycle, priceAvg is 0 and the blended entry and exit prices are in priceOpen and priceClose (the AAPL record above is an example). Use priceOpen and priceClose for entry and exit reference on every record.


When records update​

TradeZero writes or updates a closed-position record after a symbol goes flat. The record updates asynchronously, so it may not appear in the same HTTP response as the closing fill - confirm the fill first, then read this endpoint for lifecycle totals.

Recommended flow:

  1. Confirm the closing fill via GET /order/{clientOrderId} or the Portfolio WebSocket stream.
  2. Re-read /positions/closed on a 1.5–2 second interval until the symbol's aggregated sharesIn, sharesOut, and realized reflect the new activity (or show pending state from the order stream until they do).
  3. Compare sharesIn, sharesOut, and realized to detect updates. updatedDate is useful for display; the cumulative fields are what change when a lifecycle is updated.

Paper vs live​

The request and response format is identical on paper (TZP* accounts) and live: authentication, headers, error codes, and JSON structure all match. Behavioral differences:

  • Paper records persist across sessions. Lifecycles you closed in a previous paper session remain on record, so the list grows over time on an active paper account.
  • Paper P&L is simulated. realized on a paper account reprices against live quotes but reflects simulated cash rather than actual funds.

Worked examples​

A single clean round-trip​

A symbol bought and sold once at a single price comes back as a clean record - sharesIn equals sharesOut, priceAvg is populated, and realized is the simple difference:

One round-trip
{
"symbol": "UWMC",
"side": "Long",
"securityType": "Stock",
"shares": 0,
"sharesIn": 915,
"sharesOut": 915,
"priceOpen": 2.19,
"priceClose": 2.18,
"priceAvg": 2.19,
"realized": -9.15,
"positionId": "2260626195855280600",
"createdDate": "2026-06-26T19:58:55.2800054+00:00",
"updatedDate": "2026-06-26T19:59:06.1395114+00:00"
}

Here realized ≈ (2.18 − 2.19) × 915 = −9.15, and priceClose is the real exit price because the lifecycle is closed.

An aggregated multi-trip record​

The same symbol traded many times in one session aggregates into a single record. priceOpen / priceClose become blended averages, priceAvg drops to 0, and realized is the cumulative net:

Many round-trips, one record
{
"symbol": "AAPL",
"side": "Long",
"securityType": "Stock",
"shares": 0,
"sharesIn": 44,
"sharesOut": 44,
"priceOpen": 282.3288636363636,
"priceClose": 282.0618181818182,
"priceAvg": 0,
"realized": -11.75,
"positionId": "2260626202729920768",
"createdDate": "2026-06-26T20:27:29.9207954+00:00",
"updatedDate": "2026-06-26T20:30:45.9731591+00:00"
}

sharesIn and sharesOut are balanced at 44, the blended prices summarize multiple round-trips, and realized is the net across all of them.


Error responses​

ConditionStatusBody
Missing TZ-API-KEY-ID or TZ-API-SECRET-KEY404Not found\n (plain text)
Both auth headers present but values are invalid404Not found\n
Account ID does not match authenticated credentials404Not found\n
Account ID does not exist (e.g. TZP99999X)404Not found\n
Account ID is empty (/accounts//positions/closed)404404 page not found
POST / DELETE against /positions/closed404empty
PUT / HEAD against /positions/closed405empty
OPTIONS against /positions/closed200empty (CORS preflight)

Two things worth knowing when you handle errors:

  • Auth failures and unauthorized account access both return 404 Not Found. This protects account IDs from enumeration. If a previously working call starts returning 404, check credentials and account ID first.
  • Error bodies are text/plain, not JSON. Read res.text() and branch on the Content-Type header before parsing.

The endpoint is read-only and idempotent. It is subject to the same 3/s per-API-key rate limit as GET /positions and GET /pnl - see API Rate Limits. A 5 second background refresh is a reasonable default; after a close, re-read on a 1.5–2 second interval for a short window until the lifecycle totals reflect the new activity.


Historical positions​

What historical positions are for​

Use GET /positions/historical when you need:

  • Holdings as of a calendar date - trade-date quantity, market value, and unrealized P&L for each lot on that snapshot day.
  • End-of-day / as-of reconciliation - reconstruct the account book for a prior session without rebuilding it from fills.

What historical positions do not include​

This route returns a read-only as-of snapshot. It does not stream updates or accept writes.

You needUse instead
Current open lotsGET /positions
Flat lifecycle realized P&LGET /positions/closed
Session / live unrealized and day totalsGET /pnl
Per-fill historyGET /orders or GET /orders/start-date/{date}
Real-time position changesPortfolio WebSocket

At a glance​

MethodPathWhat it does
GET/v1/api/accounts/{accountId}/positions/historicalRetrieve historical positions - holdings as of a required date (YYYY-MM-DD).

Full OpenAPI reference: Retrieve Historical Positions.

Where each positions view fits

Quick start​

One GET with the account in the path and a snapshot date query. No request body.

Read historical positions
curl 'https://webapi.tradezero.com/v1/api/accounts/TZP12345678/positions/historical?date=2026-09-15' \
-H 'Accept: application/json' \
-H 'TZ-API-KEY-ID: {YOUR_PUBLIC_KEY}' \
-H 'TZ-API-SECRET-KEY: {YOUR_SECRET}'

Authentication uses the same key / secret pair as the rest of the trading API - see Authentication. The endpoint accepts GET only. Pass date as YYYY-MM-DD.


Endpoint reference​

The base URL for production is https://webapi.tradezero.com. The TZ-API-KEY-ID and TZ-API-SECRET-KEY headers are required on every call.

GET /v1/api/accounts/{accountId}/positions/historical?date={YYYY-MM-DD}
ParameterInRequiredDescription
accountIdpathYesAccount identifier
datequeryYesSnapshot day in YYYY-MM-DD

Response envelope​

The response is always a JSON object whose historicalPositions value is the array of rows:

Response
{
"historicalPositions": [
{
"account": "TZP12345678",
"symbol": "AMZN",
"securityType": "E",
"securityDescription": "",
"tdPosition": 1,
"tdMarketValue": 248.42,
"avgCost": 260.64,
"closingPrice": 248.42,
"upnl": -12.22,
"maintenance": 41.41,
"positionOpenDate": "2026-08-18T00:00:00",
"legs": []
}
]
}

When the account has no holdings for that date, the envelope is { "historicalPositions": [] }.

Fields​

FieldTypeDescription
accountstringAccount identifier
symbolstringEquity ticker or OCC option symbol
securityTypestringSecurity type (for example E for equity)
securityDescriptionstringSecurity description
tdPositionnumberTrade-date quantity (positive long, negative short)
tdMarketValuenumberTrade-date market value
avgCostnumberAverage cost
closingPricenumberClosing price used for the snapshot
upnlnumberUnrealized profit/loss for the snapshot
maintenancenumberMaintenance requirement
positionOpenDatestring (date-time)When the position was opened
legsobject[]Multi-leg payload when the snapshot includes legs

Errors and limits​

ConditionStatusNotes
Missing or invalid date400Send date as YYYY-MM-DD
Account not authorized for these credentials400Confirm accountId matches the API key
Missing or invalid authentication401 / 404Same key pair as the rest of the Trading API
Non-GET method405Use GET only (OPTIONS supported for CORS)

Rate limit: 3/s per API key - see API Rate Limits.