# Portfolio PnL

Three read-only endpoints report your team's portfolio performance. They use the same data and the same calculations as the PnL page of the [trading app](https://pro.anboto.xyz), so the API and the app show the same numbers for the same accounts and dates.

| Endpoint | Returns | Built from |
|---|---|---|
| `GET /api/v2/portfolio/pnl` | Daily PnL, returns, drawdown and Sharpe, per day and for the window | Daily account equity, minus deposits and withdrawals |
| `GET /api/v2/portfolio/pnl/ledger` | Realized PnL, funding and fees, per day and per symbol | The income history the exchange reports (Binance USD-M futures first) |
| `GET /api/v2/portfolio/flows` | Deposits, withdrawals and internal transfers | The exchange's transfer history, with duplicate legs removed |

All three are signed GET requests, like the rest of the API (see [Get started](get-started.md)). A read-only API key is enough. Results are always for the team that owns the key. Every parameter and response field is listed in the [API Reference](../reference/index.md), under **Portfolio**.

## NAV PnL or ledger?

The two PnL endpoints answer different questions, and their numbers do not match day by day.

**`/pnl` is NAV PnL.** It measures how much the account value changed, with deposits and withdrawals taken out:

```
daily_pnl = equity_close - equity_prev - net_flows
```

`equity_close` is the account equity at 00:00 UTC at the end of the day, and it includes unrealized PnL. `equity_prev` is the previous day's close. So a day's PnL contains everything that moved the account value: realized and unrealized trading PnL, funding, fees, and price changes of non-stablecoin collateral. Use `/pnl` to measure performance: returns, drawdown, Sharpe.

**`/pnl/ledger` is booked income.** It adds up what the exchange recorded as income: realized PnL on closed positions, funding payments, trading commissions, rebates and similar items. It contains no unrealized PnL. Use it to see where the performance came from: which symbols, how much funding, how much went to fees.

On any given day, the gap between the two is mostly the change in unrealized PnL and in the value of collateral. The two get closer as positions are closed, but they are not expected to be equal: the ledger also leaves out venues and items it does not cover yet (see [Ledger coverage](#ledger-coverage)).

NAV PnL covers accounts on Binance, Bybit, OKX, WOO X and Hyperliquid. Accounts on other exchanges have no NAV PnL yet.

## Selecting accounts

All three endpoints take an optional `accounts` parameter: one comma-separated string.

| Item | Selects |
|---|---|
| `binance` | Every Binance account of the team: the main account and all subaccounts |
| `binance:` | The Binance main account only |
| `binance:Sub1` | The Binance subaccount named `Sub1` |

- Leave `accounts` out to get every account of the team, like the app's "All accounts" view.
- Combine items freely, for example `accounts=binance:,bybit:Sub1,okx`. The selected accounts are added together day by day.
- Exchange codes are lower case: `binance`, `bybit`, `okx`, `woo`, `hyperliquid`.
- Send at most 20 items. Duplicates, empty items and unknown exchanges return `400`.
- Subaccount names are matched exactly, case included, and are not checked against the team's accounts. The API has no endpoint that lists them. A name with no history is not an error: `/pnl` answers `200` with an empty `series`, `point_count: 0` and `since_inception_date: null`, and does not list it in `uncovered_exchanges`. On `/pnl/ledger`, such a name on Binance gets no entry in `coverage` (on other exchanges it is listed as `unsupported`), and `/flows` returns no rows for it. Read an empty result for a named subaccount as a possible misspelling, not as zero PnL.

!!! warning "Not the same rule as `/trading/*`"
    On [Get balance](../reference/get-balance.md), [Get position](../reference/get-position.md) and [Get account margin](../reference/get-account-margin.md), leaving out `subaccount` means the default account. Here, an exchange with no subaccount means **all** of that exchange's accounts. Write `binance:` to get the main account only.

### Signing the `accounts` value

Sign the value as written, without percent-encoding: the string to sign contains `accounts=binance:,bybit:Sub1`. On the wire, `:` and `,` may be percent-encoded (`%3A`, `%2C`); the server decodes them before it checks the signature. Two limits apply:

- Send a space as `%20`, never as `+`. The server does not turn `+` back into a space, so the signature check fails. Python's `urlencode` and `requests` both use `+` by default; pass `quote_via=quote` as shown below.
- A subaccount whose name contains `,` `:` `&` `=` `#` or `+` cannot be selected on its own. Select the whole exchange instead (`binance`).

In Python, with `BASE` and `signed_headers_query` from the authentication helpers in the [API Reference](../reference/index.md):

```python
from urllib.parse import quote, urlencode

import requests

params = {"accounts": "binance:,binance:Desk 2", "period": "30d"}  # keys in alphabetical order
query = urlencode(params, quote_via=quote)  # accounts=binance%3A%2Cbinance%3ADesk%202&period=30d
r = requests.get(f"{BASE}/api/v2/portfolio/pnl?{query}", headers=signed_headers_query(params))
```

## Grouping

`/pnl` returns one combined series for the whole selection. Add `groupBy` to also get one series per group:

| `groupBy` | One group per |
|---|---|
| `none` (default) | No groups, only the total |
| `exchange` | Exchange |
| `account` | Account: an exchange's main account or one subaccount |

Money values (`daily_pnl`, `net_flows`, the equity fields, cumulative PnL) of the groups add up to the total, up to floating-point rounding. A group's `series` has only the days of `total.series` on which that group had data; on a missing day the group contributed 0. Groups also carry `today_pnl`, `period_return_pct` and `estimated`; the return is computed separately for each group and does not add up. Sharpe, drawdown and the since-inception figures are only computed for `total`: in `groups`, `sharpe`, `max_drawdown_pct` and the `since_inception_*` fields are `null`, and so are each point's `daily_return_pct`, `drawdown_pct`, `daily_unrealized_pnl` and `unrealized_pnl_close`.

In each group, `key` names the group: `exchange`, plus `subaccount` with `groupBy=account` (`""` is the main account). Key fields the grouping does not use are `null`.

This `groupBy` belongs to `/pnl`. `/pnl/ledger` has its own `groupBy` with other values (see [Ledger and flows parameters](#ledger-and-flows-parameters)).

## Dates and freshness

Days are UTC calendar days. A day closes at 00:00 UTC, and its row is written a few minutes later, so the last closed day is yesterday.

### Window

`/pnl` and `/pnl/ledger` take a window of whole days:

- Choose it with `period` (`<N>d` with N from 1 to 3660, `week`, `month`, `quarter` or `year`; default `30d`) or with `startDate` and `endDate` (`YYYY-MM-DD`, both included). `startDate` takes precedence over `period`, and `period` counts back from `endDate`. Any other `period` value returns `400`.
- On `/pnl`, `endDate` defaults to yesterday, the last closed day, and may not be later: an `endDate` of today or later returns `400`. Today's PnL so far is `today_pnl` (see [Freshness](#freshness)).
- On `/pnl/ledger`, `endDate` defaults to today and may not be later. Today's figures are provisional (see [Ledger coverage](#ledger-coverage)).
- `from_date` and `to_date` in the response give the window as applied.

`/flows` takes its window in milliseconds instead: `startMs` and `endMs`, in ms since epoch, both included. `endMs` defaults to now and `startMs` to 30 days before `endMs`. `/flows` ignores `period`, `startDate` and `endDate` without returning an error, so a request that sends them gets the last 30 days.

`scope` limits `/pnl` to the spot or the futures wallets (`total`, `spot` or `future`; default `total`). The longest window depends on the endpoint and, for `/pnl`, on `scope`:

| Request | Longest window |
|---|---|
| `/pnl` with `scope=total` | 3660 days |
| `/pnl` with `scope=spot` or `scope=future` | 366 days |
| `/pnl/ledger` | 366 days |
| `/flows` | 366 days between `startMs` and `endMs` |

A longer window returns `400`; it is not cut short.

### Freshness

- `today_pnl` on `/pnl` is the PnL of the current, unfinished day, read from the latest equity snapshot. Snapshots are taken every hour. Today's figure moves during the day; after midnight the closed day replaces it.
- `nav_usd` on `/pnl` is the current value of the selected accounts: the sum of each account's newest equity snapshot, if it is at most 3 days old. With `scope=spot` or `scope=future` it is the equity of that wallet only. It is `null` when no snapshot is available.
- `as_of` is an ISO-8601 UTC time, or `null` when there is none. On `/pnl` it is the time of the newest equity snapshot used for `today_pnl` and `nav_usd`. On `/pnl/ledger` it is the time of the newest income data read from the exchange.
- A point has `quality: "estimated"` when one of its two equity closes had to be estimated, or when one of that day's transfers had to be inferred from equity changes because the exchange's history did not record it or did not give its direction. `estimated` on the summary is `true` when any point in the window is estimated.
- Since-inception figures (`since_inception_*`) start at the first closed day of the selected accounts, not of the whole team.

## Ledger and flows parameters

Besides `accounts` and the window, the ledger and flows endpoints take these filters:

| Endpoint | Parameter | Values |
|---|---|---|
| `/pnl/ledger` | `groupBy` | One row per account and `day`, `symbol` or `day_symbol` (default). Not the same values as `groupBy` on `/pnl` |
| `/pnl/ledger` | `symbol` | One Anboto symbol, for example `BTC/USDT:USDT` |
| `/pnl/ledger` | `market` | `spot` or `future` |
| `/flows` | `types` | Comma-separated `deposit`, `withdrawal`, `internal_transfer`; default all |
| `/flows` | `external` | `true`: only deposits and withdrawals; `false`: only transfers inside the team; default both |

### Pagination

`/pnl/ledger` and `/flows` return their rows in pages:

- `limit` sets the page size: 1 to 5000 rows on `/pnl/ledger` (default 1000), 1 to 1000 flows on `/flows` (default 500).
- `next_cursor` is `null` on the last page. Otherwise, send the same request again with `cursor` set to `next_cursor`, unchanged, and sign it like any other parameter.
- `totals` covers the whole window and selection on every page, not only the rows of the current page.

To store a history, read every page until `next_cursor` is `null`. The first page alone can miss rows.

## Units

| Value | Unit |
|---|---|
| Money: PnL, equity, flows, `*_usd` | USD, as JSON numbers |
| Every `*_pct` field | Percent: `2.5` means +2.5% |
| `drawdown_pct`, `max_drawdown_pct` | Percent, zero or below: `-19.9` is 19.9% under the previous peak |
| `sharpe` | Annualized with √365 from daily returns; `null` with fewer than 2 days |

- `period_return_pct` is a simple return: the window's total PnL divided by the account equity at the start of the window. It is not time-weighted, so a large deposit or withdrawal inside the window changes its meaning. The daily `daily_return_pct` values are each day's PnL divided by the previous close.
- `null` means unknown, never zero. For example, `today_pnl` is `null` when a transfer made today could not be priced yet.
- An account's PnL for a day is unknown when that day has a transfer that could not be priced, or when the account has no two comparable equity closes. No point carries it as `null` or `0`; what happens to the date depends on the selection:
    - One account, any `scope`: the date is missing from `series`.
    - Several accounts with `scope=spot` or `scope=future`: the date is missing from `series` as soon as one selected account's PnL is unknown.
    - Several accounts with `scope=total`: the date is missing from `total.series` only when the accounts with an unknown PnL together held at least 5% of the selection's equity at the close of the previous day in `total.series`. Otherwise the day is kept without them: their PnL, flows and equity are left out of that day, like a group on a day it has no point (see [Grouping](#grouping)). An account that never comes back, for example after its API key is removed, removes at most 3 days; the days after that are kept without it.
- `uncovered_exchanges` lists the requested exchanges that have no NAV PnL (without `accounts`: the team's connected exchanges that have none). They are left out of every figure. When none of the selected exchanges is covered, `total` still comes back, with `cumulative_pnl` at `0` and an empty `series`. Read that as "not covered", not as zero PnL.

## Flows sign convention

`/flows` lists the capital movements that `/pnl` with `scope=total` takes out of the PnL, after the same de-duplication.

| Field | Meaning |
|---|---|
| `subaccount` | The account the row belongs to; `""` is the main account |
| `type` | `deposit`, `withdrawal` or `internal_transfer` |
| `direction` | `in`, `out`, `internal` or `unknown`, seen from the listed account |
| `qty` | Amount of `asset`, always positive |
| `usd_value` | USD value at the time of the transfer, or `null` if it could not be priced |
| `net_flow_usd` | Signed contribution to `net_flows` of `/pnl` with `scope=total`: positive for money in, negative for money out |
| `counted_in_pnl` | `false` when the transfer does not change `net_flows` |

- A transfer between the main account and a subaccount, or between two subaccounts, is money out of one account and money in to the other. With both accounts selected, the two legs cancel out.
- Moving funds between wallets of the same account, for example from Binance spot to USD-M futures, does not change that account's equity: `direction` is `internal`, `net_flow_usd` is `0` and `counted_in_pnl` is `false`.
- A transfer whose direction is `unknown` is not counted either: `net_flow_usd` is `0` and `counted_in_pnl` is `false`.
- A transfer that could not be priced has `usd_value: null`. It is counted in `totals.unpriced_count` and left out of the USD sums in `totals`.
- Rows are sorted by time, oldest first. The exchange's raw transfer record is never returned.

`/flows` and `net_flows` of `/pnl` match only with `scope=total`, and only on days whose transfers were all read from the exchange:

- With `scope=spot` or `scope=future`, `net_flows` of `/pnl` also counts moves between the spot and futures wallets of one account, which `/flows` reports with `net_flow_usd` at `0`.
- When the exchange's history did not record a transfer between two accounts of the team, or did not give its direction, `/pnl` can infer it from the opposite equity changes of the two accounts. It marks that day `quality: "estimated"`, and the day's `net_flows` then includes a transfer that has no counted row in `/flows`.

## Ledger coverage

The ledger only reports what it has actually read from the exchange. A gap is reported as a gap, never as a zero.

- **Covered:** Binance USD-M futures, including the USD-M side of Portfolio Margin accounts.
- **Not covered yet:** Binance spot and COIN-M futures, and all other exchanges. The response's `coverage` lists these accounts as `unsupported`.
- **History:** Binance keeps about three months of income history. Older days cannot be fetched.
- **Freshness:** income is read every hour. Today's figures are provisional, and a day becomes final about two hours after it closes at 00:00 UTC.
- **Prices:** amounts paid in a non-stablecoin asset, such as BNB commission, are valued at that asset's Binance daily close. `unpriced_assets` lists the assets that could not be valued; their amounts are missing from the USD figures.
- **Not included:** Portfolio Margin borrowing interest.

Each of the team's accounts in the selection has an entry in `coverage` that says whether its data for the window is complete; a Binance subaccount name the team does not have gets none (see [Selecting accounts](#selecting-accounts)). Its `status` is `complete`, `partial` or `unsupported`; `ingested_through` gives the time up to which income has been read, and `reason` says why an account is partial or unsupported.

Rows exist only for days with income data. A date with no row is either a covered day with no income or a day that is not covered; `coverage` tells the two apart. A day that is not covered is never sent as a row of zeros.

**Sign convention:** positive is income to the account, negative is a cost. `totals` and every row carry the same components. Rows exist only for covered days, so their components are always numbers. When no day of the window is covered, for example for an account on another exchange, with `market=spot`, or for a window older than the history Binance keeps, every component of `totals` is `null`; `coverage` says why.

| Field | Contains |
|---|---|
| `realized_pnl` | Realized PnL of closed positions |
| `funding` | Funding payments: positive when received, negative when paid |
| `commission` | Trading fees, zero or negative |
| `fee_rebate` | Fee rebates, zero or positive |
| `interest` | Borrowing interest; `0` while it is not covered (see **Not included** above) |
| `other` | Other income that counts as PnL, such as rewards |
| `net` | The sum of the fields above |

Transfers are not income and never appear in the ledger; they are listed by `/flows`.

## Request cost

- Responses are cached for about 60 seconds, so polling more often returns the same data. Daily rows change once a day, shortly after 00:00 UTC. `today_pnl` changes about once an hour.
- `scope=spot` and `scope=future` are computed from equity snapshots on every request, which is much slower than `scope=total`. Every such request recomputes the whole history of the selected accounts, from their first daily close through yesterday, whatever the window. Their cost grows with the age of the accounts, not with the window: a `period=7d` request costs as much as a 366-day one. Request them only as often as you need them.
- To build a long history, fetch it once, store it, and then request only the last few days: `period=7d` on `/pnl` with `scope=total` and on `/pnl/ledger`, or `startMs` set to 7 days ago on `/flows`. On `/pnl/ledger` and `/flows`, read every page (see [Pagination](#pagination)).
- Select only the accounts you need. `groupBy=account` over a long window returns one series per account, which makes large responses.
- Portfolio requests count against your rate limit. On `429`, back off and retry (see [Rate limits](rate-limits.md)).

## Errors

| Status | Body | When |
|---|---|---|
| `400` | JSON: `{"error": "<message>"}` | A parameter is invalid: a bad `accounts` item, an unknown exchange, a `period` that is not `<N>d`, `week`, `month`, `quarter` or `year`, a malformed date, `startDate` after `endDate` or `startMs` after `endMs`, an `endDate` after yesterday on `/pnl` or after today on `/pnl/ledger`, a window that is too long, a `symbol` on `/pnl/ledger` that is not a known symbol of a covered market or is longer than 64 characters, or a bad `limit` or `cursor` |
| `400` | Plain text: `Invalid Signature (7003)` | The signature does not match the request. A common cause here is a space sent as `+` in `accounts` (see [Signing the `accounts` value](#signing-the-accounts-value)) |
| `401` | | The API key is missing or unknown (see [Errors](errors.md)) |
| `429` | | Rate limit exceeded; back off and retry (see [Rate limits](rate-limits.md)) |
| `502` | `{"error": "<message>"}` | The portfolio service gave an answer the gateway could not use, for example one too large to pass on. Retry with a shorter window or fewer accounts (or without `groupBy=account`), and contact support if it persists |
| `503` | `{"error": "portfolio service unavailable"}` | The portfolio service is down or timed out; retry later |

Not every `400` body is JSON: check the `Content-Type` header before you parse it.
