Portfolio¶
The portfolio routes return the account's money, long-term holdings and open positions, combined across every broker as if they were one account. None of them asks a broker while you wait. Each one serves a document that a background script rebuilds every half second, or every minute for holdings, and each document says how every broker's data was read.
The table below lists the three routes on this page.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/portfolio/funds |
Cash, margin, profit and cash movements, summed across brokers, with a split by segment |
| GET | /api/portfolio/holdings |
Long-term holdings merged across brokers and priced from the live feed |
| GET | /api/portfolio/positions |
Open positions merged across brokers and priced, as a net view and a day view |
Glossary of constants¶
The table below lists the enumerated values these routes return.
| Parameter | Values | Meaning |
|---|---|---|
brokers[].status |
ok |
The broker's data was read and is recent |
brokers[].status |
stale |
The broker's data was read but is old, so its script may have stopped. It is still included. |
brokers[].status |
missing |
Nothing is stored for the broker. It contributes nothing. |
brokers[].status |
unreadable |
Something is stored but it could not be read. It contributes nothing. |
product (positions) |
delivery, intraday, carry, margin_trading, cover, bracket |
The position's product. These are not the order products CNC, MIS and NRML; see Constants. |
segments key (funds) |
equity, derivatives, currency, commodity |
The funds split a broker reports, present only when some broker reports it |
How the documents are kept¶
Three layers stand between a broker and your program. Each broker's own scripts poll it and store its data in Redis under <broker>:portfolio:*. Three combining scripts read every broker's keys, and for holdings and positions the live quotes too, and each writes one finished document under unified:portfolio:*. The API only reads that finished document.
The sequence below follows one request. The API checks the document's age before anything else, so an old document is never passed off as current.
sequenceDiagram
autonumber
participant B as Broker poller
participant RB as Redis<br/>#lt;broker#gt;:portfolio:*
participant U as Combiner<br/>bin/unified/portfolio/*
participant RU as Redis<br/>unified:portfolio:*
participant A as UBI API
participant C as Your program
B->>RB: store the broker's funds, holdings or positions
loop every 0.5 s (holdings every 60 s)
U->>RB: read all ten brokers
U->>U: combine, price, mark each broker's status
U->>RU: SET the document with as_of
end
C->>A: GET /api/portfolio/positions
A->>RU: GET unified:portfolio:positions
alt missing or unreadable
A-->>C: 503 not available
else as_of older than allowed
A-->>C: 503 out of date, with as_of and brokers
else no broker ok or stale
A-->>C: 502 unable to retrieve, with brokers
else
A-->>C: 200 the document
end
How old an answer can be¶
Each route has a maximum age. A document older than that means its combining script has stopped, and the route answers 503 instead of serving it. Inside the document, each broker also has its own staleness limit, after which that broker is marked stale but still counted.
The table below lists the three timings for each route, all in seconds.
| Route | Document rewritten every | Oldest document served | Broker marked stale after |
|---|---|---|---|
funds |
0.5 | 30 | 60 |
holdings |
60 | 300 | 180 |
positions |
0.5 | 30 | 60 |
The chart below draws the same numbers on a logarithmic scale, because they range from half a second to five minutes. The gap between the first bar and the second is the slack each route allows before it refuses to answer.
Status codes shared by all three routes¶
The three routes share one reader, so they fail in the same ways. The table below lists every status; <Subject> is Funds, Holdings or Positions, and <key> is the route's Redis key.
| Status | When |
|---|---|
| 200 | The document is recent and at least one broker is ok or stale. |
| 401 | Access token is required, Invalid access token or Access token has expired. |
| 502 | Unable to retrieve <subject> information: the document is recent, but every broker is missing or unreadable. The body also carries brokers. |
| 503 | <Subject> are not available: nothing is keeping <key>: the key is missing or not a JSON object, so the combining script is not running or has never run. |
| 503 | <Subject> are out of date: <key> was last written at <as_of>: the document is older than the route allows. The body also carries as_of and brokers. |
An out-of-date answer looks like the example below, shortened to two brokers.
{
"error": "Positions are out of date: unified:portfolio:positions was last written at 2026-09-26T11:02:13",
"as_of": "2026-09-26T11:02:13",
"brokers": [
{"broker": "dhan", "status": "ok", "as_of": "2026-09-26T11:02:12"},
{"broker": "zerodha", "status": "missing", "as_of": null}
]
}
Funds¶
GET /api/portfolio/fundsaccess-token
This route returns the account's funds with every figure summed across brokers and rounded to paise. It serves unified:portfolio:funds, which bin/unified/portfolio/funds rewrites every half second from each broker's <broker>:portfolio:funds key.
Request parameters¶
| Name | In | Type | Required | Description |
|---|---|---|---|---|
access-token |
header | string | Yes | The token from connect |
Example¶
Response¶
The example below is shortened to one segment and two brokers, and every amount is illustrative.
{
"summary": {
"total_balance": 250000.0,
"available_balance": 180000.0,
"cash_balance": 150000.0,
"collateral_value": 90000.0,
"adhoc_credit": 0.0,
"margin_utilized": 70000.0,
"withdrawable_balance": 80000.0
},
"pnl": {"realized": 1250.5, "unrealized": -340.0},
"margin_breakdown": {"span_margin": 52000.0, "exposure_margin": 15000.0, "other_margin": 3000.0},
"cash_movement": {"pay_in_today": 0.0, "pay_out_today": 0.0, "uncleared_funds": 0.0, "pending_withdrawal": 0.0},
"segments": {
"equity": {"available_balance": 120000.0, "margin_utilized": 40000.0, "span_margin": 30000.0, "exposure_margin": 8000.0}
},
"brokers": [
{"broker": "dhan", "status": "ok", "as_of": "2026-09-26 11:02:12.841233", "available_balance": 64000.0, "pools": {}},
{"broker": "zerodha", "status": "ok", "as_of": "2026-09-26 11:02:12.902114", "available_balance": 56000.0, "pools": {"equity": 56000.0, "commodity": 0.0}},
{"broker": "wisdom_capital", "status": "unreadable", "as_of": "2026-09-26 11:02:12.101002", "error": "NotFunds: ..."}
],
"as_of": "2026-09-26T11:02:13"
}
Response attributes¶
| Attribute | Type | Description |
|---|---|---|
summary.total_balance |
number | available_balance plus margin_utilized, because no broker reports a total |
summary.available_balance |
number | What is free to trade |
summary.cash_balance |
number | Cash |
summary.collateral_value |
number | The value of pledged collateral |
summary.adhoc_credit |
number | Ad hoc limits the broker has granted |
summary.margin_utilized |
number | Margin already blocked |
summary.withdrawable_balance |
number | What can be withdrawn |
pnl.realized, pnl.unrealized |
number | Profit and loss, as the brokers report it |
margin_breakdown |
object | span_margin, exposure_margin and other_margin |
cash_movement |
object | pay_in_today, pay_out_today, uncleared_funds and pending_withdrawal |
segments |
object | One entry per segment any broker reports, each with available_balance, margin_utilized, span_margin and exposure_margin |
brokers |
array | Each broker's broker, status and as_of (when its script stored the funds, YYYY-MM-DD HH:MM:SS.ffffff), plus error when it is unreadable |
brokers[].available_balance |
number | That broker's own free cash, present when its status is ok or stale |
brokers[].pools |
object | The segments whose free cash that broker reports separately, each with its available balance: equity and commodity at Zerodha and Fyers, equity and derivatives at Groww, and those three at INDmoney. It is empty for a broker with one pool. |
as_of |
string | When the document was written, YYYY-MM-DDTHH:MM:SS in the server's local time |
A field a broker does not report adds nothing, and a missing, blank or non-numeric value, such as Wisdom Capital's "NaN", is read as zero. A broker is marked stale when its funds were stored more than 60 seconds ago. A broker is unreadable when what is stored is not funds, for example a refusal that Fyers, the Noren brokers or Kotak carried inside a successful answer.
Groww and INDmoney report their free cash once per product, such as delivery, intraday, futures, option buying and option selling. Those figures are the same money seen from different products, so a segment's available_balance is the smallest of them rather than their sum.
The per-broker available_balance and pools are also what the lowest-cost broker selector reads to decide whether a broker can afford an order; see Broker selection.
Under the hood
- Route:
PortfolioBlueprint.fundsinunified_broker_interface/blueprints/portfolio.py, callingread_documentwith a maximum age of 30 seconds. - Combiner:
bin/unified/portfolio/funds. Its module docstring has a table of which broker field feeds each figure. - Redis: reads
<broker>:portfolio:fundsfor all ten brokers in oneMGET, and writesunified:portfolio:fundswithSET.
Holdings¶
GET /api/portfolio/holdingsaccess-token
This route returns long-term holdings, with each security held at several brokers merged into one row and priced from the live feed. It serves unified:portfolio:holdings, which bin/unified/portfolio/holdings rewrites every minute, so a document up to five minutes old is served.
Request parameters¶
| Name | In | Type | Required | Description |
|---|---|---|---|---|
access-token |
header | string | Yes | The token from connect |
Example¶
Response¶
The example below is shortened to one holding and two brokers. The id is a placeholder, and the quantities and prices are illustrative.
{
"holdings": [
{
"instrument_id": "11111111-1111-5111-8111-000000000002",
"isin": "INE009A01021",
"symbol": "INFY",
"exchange": "nse",
"segment": "nse_equities",
"quantity": 20.0,
"average_price": 1400.0,
"invested_value": 28000.0,
"last_price": 1521.4,
"current_value": 30428.0,
"pnl": {"unrealized": 2428.0, "day_change": 13.25, "day_change_percentage": 0.88},
"collateral_quantity": 0.0
}
],
"summary": {"holdings_count": 1, "total_investment": 28000.0, "total_current_value": 30428.0, "total_unrealized_pnl": 2428.0},
"brokers": [
{"broker": "dhan", "status": "ok", "as_of": "2026-09-26 11:01:40.112000"},
{"broker": "zerodha", "status": "stale", "as_of": "2026-09-26 10:55:02.500000"}
],
"as_of": "2026-09-26T11:02:00"
}
Response attributes¶
| Attribute | Type | Description |
|---|---|---|
holdings[].instrument_id |
string or null | The instrument, or null when the holding could not be matched to one |
holdings[].isin |
string or null | The ISIN, when a broker sent one |
holdings[].symbol |
string | The instrument's symbol, or the broker's own symbol when unmatched |
holdings[].exchange |
string | The exchange |
holdings[].segment |
string or null | The exchange-prefixed segment, or null when unmatched |
holdings[].quantity |
number | Everything held, across every broker and every bucket (settled, T1, margin trading) |
holdings[].average_price |
number | invested_value divided by quantity |
holdings[].invested_value |
number | Each bucket at its own average price, summed |
holdings[].last_price |
number or null | The price used, as described below |
holdings[].current_value |
number or null | quantity times last_price |
holdings[].pnl |
object | unrealized (current value less invested value), day_change and day_change_percentage |
holdings[].collateral_quantity |
number | The quantity pledged as collateral |
summary |
object | holdings_count, total_investment, total_current_value and total_unrealized_pnl |
brokers |
array | Each broker's broker, status and as_of, when its script stored the holdings |
as_of |
string | When the document was written, YYYY-MM-DDTHH:MM:SS |
Holdings are sorted by symbol. Two brokers' holdings become one row when they share an ISIN or an instrument id, and their quantities, invested values and pledged quantities add up. A broker is marked stale when its holdings were stored more than 180 seconds ago.
A holding is priced in the order below, taking the first price that exists.
- The instrument's live quote in
unified:quotes:live, when that quote is usable by the same rule the quote routes apply: it is not marked stale, and it was received in the last five minutes or its trading window has closed since and not reopened. - The last price a broker sent.
- The broker's previous close, in which case there is no day change.
Groww, INDmoney and Wisdom Capital send no price at all, so a holding that only they carry and that has no usable quote has no last_price, current_value, unrealized or day change.
Under the hood
- Route:
PortfolioBlueprint.holdings, callingread_documentwith a maximum age of 300 seconds. - Combiner:
bin/unified/portfolio/holdings. Its module docstring has a table of each broker's holding fields. - Matching: a holding's token is looked up in
unified:broker_tokensamong the cash segments. Groww sends no token, so a Groww holding is looked up by symbol inunified:instrument_symbols.
Positions¶
GET /api/portfolio/positionsaccess-token
This route returns open positions merged across brokers, in two views. The net view is every position open now, whether carried from earlier or taken today. The day view is today's activity alone, which only Zerodha and Wisdom Capital report, so it holds only theirs. The route serves unified:portfolio:positions, which bin/unified/portfolio/positions rewrites every half second.
Request parameters¶
| Name | In | Type | Required | Description |
|---|---|---|---|---|
access-token |
header | string | Yes | The token from connect |
Example¶
Response¶
The example below is shortened to one net position and two brokers. The id is a placeholder, and the quantities and prices are illustrative.
{
"net": [
{
"instrument_id": "11111111-1111-5111-8111-000000000001",
"symbol": "CRUDEOIL",
"exchange": "mcx",
"segment": "mcx_commodity_futures",
"expiry_date": "2026-10-19",
"strike_price": null,
"option_type": null,
"product": "carry",
"quantity": -100.0,
"by_broker": {"dhan": -100.0},
"buy": {"quantity": 0.0, "average_price": 0.0, "value": 0.0},
"sell": {"quantity": 100.0, "average_price": 5730.0, "value": 573000.0},
"average_price": 5730.0,
"last_price": 5712.0,
"pnl": {"realized": 0.0, "unrealized": 1800.0, "total": 1800.0},
"day_change": 22.0,
"day_change_percentage": 0.39
}
],
"day": [],
"summary": {
"net": {"count": 1, "realized_pnl": 0.0, "unrealized_pnl": 1800.0, "total_pnl": 1800.0},
"day": {"count": 0, "realized_pnl": 0.0, "unrealized_pnl": 0.0, "total_pnl": 0.0}
},
"brokers": [
{"broker": "dhan", "status": "ok", "as_of": "2026-09-26T11:02:12"},
{"broker": "groww", "status": "missing", "as_of": null}
],
"as_of": "2026-09-26T11:02:13"
}
Response attributes¶
| Attribute | Type | Description |
|---|---|---|
net, day |
array of objects | The positions in each view, sorted by symbol, expiry, strike, option type and product |
[].instrument_id |
string or null | The instrument, or null when the position could not be matched to one |
[].symbol |
string | The symbol for a security or the underlying for a derivative, or the broker's own symbol when unmatched |
[].exchange |
string | The exchange |
[].segment |
string or null | The exchange-prefixed segment |
[].expiry_date, [].strike_price, [].option_type |
string, number, string, or null | The contract, for a derivative |
[].product |
string | delivery, intraday, carry, margin_trading, cover or bracket |
[].quantity |
number | The net quantity in units, positive for long and negative for short |
[].by_broker |
object | Each broker's signed share of quantity, by broker name, so a closing order can be told apart from an opening one at the broker that holds the position |
[].buy, [].sell |
object | Each side's quantity, average_price and value |
[].average_price |
number | Buy value less sell value, divided by the net quantity |
[].last_price |
number or null | From the live quote when there is one, otherwise the price a broker sent |
[].pnl |
object | realized, unrealized and total, as the brokers report them |
[].day_change, [].day_change_percentage |
number or null | The change from the previous close |
summary.net, summary.day |
object | count, realized_pnl, unrealized_pnl and total_pnl for each view |
brokers |
array | Each broker's broker, status and as_of, YYYY-MM-DDTHH:MM:SS |
as_of |
string | When the document was written, YYYY-MM-DDTHH:MM:SS |
Positions at several brokers become one row when they share an instrument and a product, and quantities, values and profits add up. The price comes from the live quote, but profit stays the brokers' own figure.
A broker's as_of is the newer of its newest position update and its last successful poll. A broker is ok when that is within 60 seconds and stale after that. A broker with no positions that is being polled is ok with nothing in the lists, and missing means its scripts have not run today at all.
Under the hood
- Route:
PortfolioBlueprint.positions, callingread_documentwith a maximum age of 30 seconds. - Combiner:
bin/unified/portfolio/positions. Its module docstring has a table of each broker's position fields. - Redis: reads the hash
<broker>:portfolio:positions, merged from REST polls and, for Fyers, Groww, Kotak and Wisdom Capital, from their position update streams, and<broker>:portfolio:positions:polled_at. - See Orders and positions and Portfolio for the broker side.