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.
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).
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
accountsout 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:
/pnlanswers200with an emptyseries,point_count: 0andsince_inception_date: null, and does not list it inuncovered_exchanges. On/pnl/ledger, such a name on Binance gets no entry incoverage(on other exchanges it is listed asunsupported), and/flowsreturns 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'surlencodeandrequestsboth use+by default; passquote_via=quoteas 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>dwith N from 1 to 3660,week,month,quarteroryear; default30d) or withstartDateandendDate(YYYY-MM-DD, both included).startDatetakes precedence overperiod, andperiodcounts back fromendDate. Any otherperiodvalue returns400. - On
/pnl,endDatedefaults to yesterday, the last closed day, and may not be later: anendDateof today or later returns400. Today's PnL so far istoday_pnl(see Freshness). - On
/pnl/ledger,endDatedefaults to today and may not be later. Today's figures are provisional (see Ledger coverage). from_dateandto_datein 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_pnlon/pnlis 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_usdon/pnlis the current value of the selected accounts: the sum of each account's newest equity snapshot, if it is at most 3 days old. Withscope=spotorscope=futureit is the equity of that wallet only. It isnullwhen no snapshot is available.as_ofis an ISO-8601 UTC time, ornullwhen there is none. On/pnlit is the time of the newest equity snapshot used fortoday_pnlandnav_usd. On/pnl/ledgerit 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.estimatedon the summary istruewhen 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:
limitsets the page size: 1 to 5000 rows on/pnl/ledger(default 1000), 1 to 1000 flows on/flows(default 500).next_cursorisnullon the last page. Otherwise, send the same request again withcursorset tonext_cursor, unchanged, and sign it like any other parameter.totalscovers 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_pctis 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 dailydaily_return_pctvalues are each day's PnL divided by the previous close.nullmeans unknown, never zero. For example,today_pnlisnullwhen 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
nullor0; what happens to the date depends on the selection:- One account, any
scope: the date is missing fromseries. - Several accounts with
scope=spotorscope=future: the date is missing fromseriesas soon as one selected account's PnL is unknown. - Several accounts with
scope=total: the date is missing fromtotal.seriesonly when the accounts with an unknown PnL together held at least 5% of the selection's equity at the close of the previous day intotal.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.
- One account, any
uncovered_exchangeslists the requested exchanges that have no NAV PnL (withoutaccounts: the team's connected exchanges that have none). They are left out of every figure. When none of the selected exchanges is covered,totalstill comes back, withcumulative_pnlat0and an emptyseries. 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:
directionisinternal,net_flow_usdis0andcounted_in_pnlisfalse. - A transfer whose direction is
unknownis not counted either:net_flow_usdis0andcounted_in_pnlisfalse. - A transfer that could not be priced has
usd_value: null. It is counted intotals.unpriced_countand left out of the USD sums intotals. - 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=spotorscope=future,net_flowsof/pnlalso counts moves between the spot and futures wallets of one account, which/flowsreports withnet_flow_usdat0. - When the exchange's history did not record a transfer between two accounts of the team, or did not give its direction,
/pnlcan infer it from the opposite equity changes of the two accounts. It marks that dayquality: "estimated", and the day'snet_flowsthen 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
coveragelists these accounts asunsupported. - 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_assetslists 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_pnlchanges about once an hour. scope=spotandscope=futureare computed from equity snapshots on every request, which is much slower thanscope=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: aperiod=7drequest 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=7don/pnlwithscope=totaland on/pnl/ledger, orstartMsset to 7 days ago on/flows. On/pnl/ledgerand/flows, read every page (see Pagination). - Select only the accounts you need.
groupBy=accountover 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.