Skip to content

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, 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). 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, under Portfolio.

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).

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.

Not the same rule as /trading/*

On Get balance, Get position and Get account margin, 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:

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).

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).
  • On /pnl/ledger, endDate defaults to today and may not be later. Today's figures are provisional (see 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). 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). 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).
  • 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).

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)
401 The API key is missing or unknown (see Errors)
429 Rate limit exceeded; back off and retry (see Rate limits)
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.