Closed Positions
Once you trade a symbol on a TradeZero account back to flat - every share bought has been sold, or every share sold short has been covered - TradeZero keeps a closed position lifecycle for that symbol. The Closed Positions API returns those records in one read:
GET /v1/api/accounts/{accountId}/positions/closed- flat lifecycle rows under theclosedPositionskey, each with symbol, side, security type,realizedP&L, cumulativesharesIn/sharesOut, and blendedpriceOpen/priceClose.
Each row is a completed symbol lifecycle, not an open lot, not a single fill, and not a working order. TradeZero computes realized P&L, share totals, and blended prices for the lifecycle so your integration reads finished numbers instead of reconstructing them from order history.
This endpoint pairs with Open Positions: /positions is what you hold now; /positions/closed is what you have already flattened. Authentication, headers, and most row fields match - closed records add realized, sharesIn, and sharesOut.
What this endpoint is 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 this endpoint does not do
The route is a read-only snapshot of closed lifecycles. It does not push updates, accept writes, or filter on the server.
| You need | Use instead |
|---|---|
| Open lots and current size | GET /positions |
| Unrealized P&L and account day totals | GET /pnl (dayRealized, dayPnl, per-lot unrealized) |
| Individual fills and order status | GET /orders or GET /orders/start-date/{date} |
| Filter by symbol, date range, or page on the server | Not 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 notifications | Portfolio 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 positions | POST /order - this route accepts GET only. |
At a glance
| Method | Path | What it does |
|---|---|---|
| GET | /v1/api/accounts/{accountId}/positions/closed | Retrieve closed positions - flat lifecycles with realized P&L, cumulative sharesIn / sharesOut, and blended priceOpen / priceClose. |
Full OpenAPI reference: Retrieve Closed Positions.
The Closed Positions API describes realized P&L on lifecycles that are already flat. For everything else:
- Open lots and unrealized P&L - use
GET /v1/api/accounts/{accountId}/positionsandGET /v1/api/accounts/{accountId}/pnl. - Per-fill detail - this endpoint aggregates a whole lifecycle into one record. For the individual executions behind it, use
GET /v1/api/accounts/{accountId}/orders(today's working and recently-closed orders, filter byexecuted > 0) orGET /v1/api/accounts/{accountId}/orders/start-date/{startDate}(up to one week of historical orders, one row per fill). - Account balances and day totals - aggregate values such as
dayRealizedlive on/pnl; the richer per-account view lives onGET /v1/api/account/{accountId}.
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.
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.
Two behaviors shape how you should parse and aggregate responses. Read The aggregation model and positionId handling before you write client code:
- Records are per-symbol-lifecycle aggregates, not per-trade.
realized,sharesIn, andsharesOutare 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. positionIdis a large JSON number on the wire. In JavaScript and other IEEE-754 runtimes, values aboveNumber.MAX_SAFE_INTEGERmust be held as a string to preserve the exact identifier. SeepositionIdhandling.
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.)
{
"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 stringThe 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
| Field | Type | Description |
|---|---|---|
accountId | string | The account that owns the record. |
positionId | number | Identifier 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. |
symbol | string | The 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. |
tradedSymbol | string | null | null for equities; the OCC contract for options (e.g. "SPY260717C00738000"). This is the per-contract key to aggregate options on. |
rootSymbol | string | null | null on both equity and option records. Use symbol for the underlying and tradedSymbol for the option contract. |
securityType | string | "Stock" for equities; "Option" for option contracts (single-leg and each leg of a multi-leg strategy). Same field set as open positions. |
side | string | "Long" or "Short". See Side on closed records. |
shares | number | Always 0 - these are flat lifecycles. Open size lives on /positions. |
sharesIn | number | Cumulative quantity bought into the lifecycle (buys and covers). For options this is a count of contracts, not underlying shares. Accumulates across trips. |
sharesOut | number | Cumulative quantity sold out of the lifecycle (sells and shorts). For options this is a count of contracts. Accumulates across trips. |
priceOpen | number | Quantity-weighted blended average entry price. For options this is the per-share option premium (e.g. 11.04), not the per-contract cost. |
priceClose | number | Quantity-weighted blended average exit price. For options this is the per-share option premium. |
priceAvg | number | Entry average on single-trip lifecycles; 0 when the lifecycle spans multiple trips. Use priceOpen / priceClose for blended entry and exit on every record (details). |
realized | number | Cumulative 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. |
priceStrike | number | Option strike as a plain number (e.g. 738); 0 for equities. |
putCall | string | "Call" or "Put" for options; "None" for equities. |
dayOvernight | string | "Day" or "Overnight" classification for the lifecycle. |
marginRequirement | number | Margin reserved; 0 on a flat record. |
maintenanceRequirement | number | Maintenance reserved; 0 on a flat record. |
createdDate | string | ISO 8601 with offset, seven fractional digits (e.g. "...+00:00"). When the record was created. |
updatedDate | string | ISO 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:
realizedis the cumulative net P&L of the lifecycle, not the result of one trade.sharesIn/sharesOutare running totals that grow as you keep trading the symbol.priceOpen/priceCloseare 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.
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;
}
symbol; options group by tradedSymbolThe 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:
symbolis the underlying root (e.g."SPY"); the OCC contract is intradedSymbol(e.g."SPY260717C00738000").rootSymbolisnull.securityTypeis"Option",putCallis"Call"or"Put", andpriceStrikecarries the strike.sharesIn/sharesOutcount contracts, not underlying shares - one contract round-tripped issharesIn: 1, sharesOut: 1.priceOpen/priceClose/priceAvgare per-share option premiums (e.g.11.04), whilerealizedis 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:
{
"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:
{
"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.
tradedSymbol, not symbolEvery 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
positionIdas 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 Positions,
positionIdis 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:
- Confirm the closing fill via
GET /order/{clientOrderId}or the Portfolio WebSocket stream. - Re-read
/positions/closedon a 1.5–2 second interval until the symbol's aggregatedsharesIn,sharesOut, andrealizedreflect the new activity (or show pending state from the order stream until they do). - Compare
sharesIn,sharesOut, andrealizedto detect updates.updatedDateis 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. The behavioral notes to keep in mind:
- 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.
realizedon 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:
{
"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:
{
"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
| Condition | Status | Body |
|---|---|---|
Missing TZ-API-KEY-ID or TZ-API-SECRET-KEY | 404 | Not found\n (plain text) |
| Both auth headers present but values are invalid | 404 | Not found\n |
| Account ID does not match authenticated credentials | 404 | Not found\n |
Account ID does not exist (e.g. TZP99999X) | 404 | Not found\n |
Account ID is empty (/accounts//positions/closed) | 404 | 404 page not found |
POST / DELETE against /positions/closed | 404 | empty |
PUT / HEAD against /positions/closed | 405 | empty |
OPTIONS against /positions/closed | 200 | empty (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 returning404, verify your credentials and account ID first. - Error bodies are
text/plain, not JSON. Readres.text()and branch on theContent-Typeheader 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.
Related documentation
- Closed Positions Recap - fetch
closedPositions[], aggregate cumulativerealizedby symbol (ortradedSymbolfor options), and poll for async lifecycle updates after a flat close. - Authentication - generate and attach your API key pair.
- Retrieve Closed Positions (API reference) - generated OpenAPI page for this endpoint.
- Open Positions - the lots you currently hold, plus per-position unrealized P&L.
- Equity Trading - placing the orders that open and close positions.
- Account Information - account-level balances and day totals.
- Portfolio WebSocket - live order and position updates.
- API Rate Limits - per-key limits across the portfolio endpoints.